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:
m7amedez5511
2026-07-21 13:15:22 +03:00
parent 55f76005c9
commit f1a521dd5a
13 changed files with 734 additions and 1099 deletions

View File

@@ -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>
);
}

View File

@@ -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>
)}