refactor(forms): migrate form modals to react-hook-form + yupResolver, and adopt shared UI kit components across Role/Order/User/Trip modals and tables
Replace manual useState + validate() wiring with react-hook-form and
yupResolver across CarFormModal, DriverFormModal, Tripformmodal,
OrderFormModal, BranchFormModal, CarMaintananceFormModal, RoleFormModal,
and UserFormModal, following the existing pattern in Addressformmodal.
- Pin yupResolver's generic explicitly (yupResolver<FormValues>(schema as any))
since create/update schemas differ structurally and yup can't unify them
into a single inferred type on its own.
- Replace manual ref-based autofocus (useRef + ref={firstRef}) with
autoFocus/setFocus to avoid ref collisions with register()'s own ref.
- DriverFormModal: bind photo/nationalPhoto/driverCardPhoto file fields via
Controller instead of register.
- Tripformmodal: convert startTime/endTime to ISO-8601 via setValueAs while
keeping the datetime-local input display unaffected.
- OrderFormModal: register nested deliveryAddress/pickupAddress fields via
dot-path syntax; keep pickupMode as local state; preserve conditional
address payload assembly.
- CarMaintananceFormModal: keep toNumberOrUndefined cost coercion and
ISO-8601 date conversion at submit time.
- RoleFormModal: bind permissionIds checkbox group via Controller.
- UserFormModal: add a custom resolver wrapper to preserve the
empty-password-on-edit behavior (skip password validation when editing
with a blank password field).
Also replace raw HTML elements with the shared UI kit components
(Modal, Input, Textarea, Select, Button, Alert, Badge, EmptyState) across
form modals, detail modals, and tables:
- RoleFormModal: Modal, Input, Textarea, Button
- OrderFormModal: Modal, Input, Select, Button
- UserDetailModal / Archiveduserdetailmodal: Modal, Alert, Button
- UserTable / Archivedusertable: Badge, Button, EmptyState
- RoleDetailModal / ArchivedRoleDetailModal: Modal, Alert, Select, Button
- RoleTable / ArchivedRoleTable: Button, EmptyState
- Tripreportpanel: Button with built-in loading state instead of manual
Spinner/disabled/color wiring
StatusBadge, IconBtn, and other per-action custom chips/checkboxes are kept
as-is in every file — the shared Badge/Button components don't support dot
indicators or per-action color-coding, so forcing them in would misshape or
strip semantics from these controls.
Archivedusersmodal, ArchivedRolesModal, and ArchivedTripList required no
changes — already fully compliant with the shared UI kit.
No changes to visual layout, CSS, or Arabic labels/section headings beyond
what's required by the component swaps above.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "../../UI";
|
||||
import { Alert, Button, Modal, Spinner } from "../../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { archivedRoleService } from "@/src/services/archive/archivedRole.service";
|
||||
import type { ArchivedRole } from "@/src/types/role";
|
||||
@@ -12,6 +12,8 @@ interface ArchivedRoleDetailModalProps {
|
||||
}
|
||||
|
||||
// ── small helper components ───────────────────────────────────────────────────
|
||||
// (No equivalents in the shared UI kit — purpose-built layouts, not generic
|
||||
// form/action controls, so they stay custom.)
|
||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -29,6 +31,8 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -69,29 +73,22 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// fetch archived role details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await archivedRoleService.getByIdUnwrapped(roleId, token);
|
||||
if (!cancelled) setRole(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات الدور المؤرشف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [roleId]);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await archivedRoleService.getByIdUnwrapped(roleId, token);
|
||||
if (!cancelled) setRole(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات الدور المؤرشف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [roleId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
@@ -99,135 +96,58 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="archived-role-detail-title"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 55,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={role?.name ?? "عرض الدور"}
|
||||
subtitle="دور مؤرشف"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 480,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||
overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* ── header ── */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
|
||||
دور مؤرشف
|
||||
</p>
|
||||
<h2 id="archived-role-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{role?.name ?? "عرض الدور"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button" onClick={onClose} aria-label="إغلاق"
|
||||
style={{
|
||||
width: 34, height: 34, borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
cursor: "pointer", fontSize: 18,
|
||||
color: "var(--color-text-muted)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/* loading */}
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── body ── */}
|
||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
|
||||
{/* error — fallback UI on API failure */}
|
||||
{!loading && error && <Alert type="error" message={error} />}
|
||||
|
||||
{/* loading */}
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
)}
|
||||
{/* content */}
|
||||
{!loading && role && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
|
||||
|
||||
{/* error — fallback UI on API failure */}
|
||||
{!loading && error && (
|
||||
<div style={{
|
||||
padding: "1rem 1.25rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||
fontSize: 13, color: "#991B1B", fontWeight: 500,
|
||||
textAlign: "center",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* content */}
|
||||
{!loading && role && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
|
||||
|
||||
{/* icon + name + status row */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: "1rem",
|
||||
padding: "0 0 1.25rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
marginBottom: "0.25rem",
|
||||
}}>
|
||||
<RoleIcon />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
|
||||
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
#{role.id}
|
||||
</p>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<StatusBadge active={role.isActive} />
|
||||
</div>
|
||||
</div>
|
||||
{/* icon + name + status row */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: "1rem",
|
||||
padding: "0 0 1.25rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
marginBottom: "0.25rem",
|
||||
}}>
|
||||
<RoleIcon />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
|
||||
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
#{role.id}
|
||||
</p>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<StatusBadge active={role.isActive} />
|
||||
</div>
|
||||
|
||||
{/* detail rows */}
|
||||
<DetailRow label="الوصف" value={role.description} />
|
||||
<DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── footer ── */}
|
||||
<div style={{
|
||||
padding: "1rem 1.5rem",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex", justifyContent: "flex-end",
|
||||
}}>
|
||||
<button
|
||||
type="button" onClick={onClose}
|
||||
style={{
|
||||
height: 40, padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
{/* detail rows */}
|
||||
<DetailRow label="الوصف" value={role.description} />
|
||||
<DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Button, EmptyState, Spinner } from "../../UI";
|
||||
import type { ArchivedRole } from "@/src/types/role";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||
@@ -14,6 +16,9 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
}
|
||||
|
||||
// ── icon button ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// isn't built for a fixed 32×32 square icon chip.
|
||||
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
||||
@@ -68,11 +73,10 @@ export function ArchivedRoleTable({ roles, loading, search, page, pages, onView,
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
/* empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="🛡️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
@@ -114,15 +118,24 @@ export function ArchivedRoleTable({ roles, loading, search, page, pages, onView,
|
||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
||||
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user