Compare commits

..

2 Commits

Author SHA1 Message Date
m7amedez5511
f1a521dd5a 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.
2026-07-21 13:15:22 +03:00
m7amedez5511
55f76005c9 update page to using same tamplet in all project not using inline code 2026-07-20 16:03:07 +03:00
33 changed files with 2441 additions and 4011 deletions

View File

@@ -1,46 +1,12 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form";
import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal } from "../UI";
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator"; import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
import type { Branch, BranchFormData, FormErrors } from "@/src/types/branch"; import type { Branch, BranchFormData } from "@/src/types/branch";
// ── fixed styles ───────────────────────────────────────── const FORM_ID = "branch-form";
const S = {
input: {
width: "100%", height: 40, padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
} as React.CSSProperties,
label: {
display: "flex", flexDirection: "column" as const,
gap: 6, fontSize: 12, fontWeight: 600,
color: "var(--color-text-secondary)",
} as React.CSSProperties,
errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties,
};
// ── yup validation ────────────────────────────────────────────────────────────
async function validate(data: BranchFormData, isNew: boolean): Promise<FormErrors> {
const schema = isNew ? createBranchSchema : updateBranchSchema;
try {
await schema.validate(data, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<FormErrors>((acc, e) => {
const field = e.path as keyof FormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
}
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
interface BranchFormModalProps { interface BranchFormModalProps {
@@ -53,209 +19,194 @@ interface BranchFormModalProps {
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) { export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
const isNew = editBranch === null; const isNew = editBranch === null;
const [form, setForm] = useState<BranchFormData>({ const {
name: editBranch?.name ?? "", register,
email: editBranch?.email ?? "", handleSubmit,
phone: editBranch?.phone ?? "", setError,
country: editBranch?.country ?? "SA", formState: { errors, isSubmitting },
city: editBranch?.city ?? "", } = useForm<BranchFormData>({
state: editBranch?.state ?? "", // Cast the schema itself and pin the generic explicitly on yupResolver —
district: editBranch?.district ?? "", // create/update schemas differ structurally in which fields are required
street: editBranch?.street ?? "", // (e.g. city/street are optional on update), so yup can't unify them
buildingNo: editBranch?.buildingNo ?? "", // into the single flat BranchFormData shape on its own.
unitNo: editBranch?.unitNo ?? "", resolver: yupResolver<BranchFormData>((isNew ? createBranchSchema : updateBranchSchema) as any),
zipCode: editBranch?.zipCode ?? "", defaultValues: {
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "", name: editBranch?.name ?? "",
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "", email: editBranch?.email ?? "",
phone: editBranch?.phone ?? "",
country: editBranch?.country ?? "SA",
city: editBranch?.city ?? "",
state: editBranch?.state ?? "",
district: editBranch?.district ?? "",
street: editBranch?.street ?? "",
buildingNo: editBranch?.buildingNo ?? "",
unitNo: editBranch?.unitNo ?? "",
zipCode: editBranch?.zipCode ?? "",
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "",
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "",
},
}); });
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { firstInputRef.current?.focus(); }, []); const submitHandler = async (data: BranchFormData) => {
useEffect(() => { const ok = await onSubmit(data, isNew);
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; if (ok) {
window.addEventListener("keydown", handler); onClose();
return () => window.removeEventListener("keydown", handler); } else {
}, [onClose]); setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
}
const set = (field: keyof BranchFormData) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
setForm(p => ({ ...p, [field]: e.target.value }));
// clear field error on change
if (errors[field]) setErrors(p => ({ ...p, [field]: undefined }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errs = await validate(form, isNew);
if (Object.keys(errs).length) { setErrors(errs); return; }
setSaving(true);
setApiError("");
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}; };
// dynamic styles for inputs with errors
const inputStyle = (field: keyof FormErrors): React.CSSProperties => ({
...S.input,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="branch-modal-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={isNew ? "فرع جديد" : editBranch?.name ?? ""}
style={{ subtitle={isNew ? "إضافة فرع" : "تعديل فرع"}
position: "fixed", inset: 0, zIndex: 50, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", size="md"
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", footer={
}} <>
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
إلغاء
</Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
{isNew ? "إضافة الفرع" : "حفظ التغييرات"}
</Button>
</>
}
> >
<div <form
onClick={e => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", maxWidth: 560, noValidate
background: "var(--color-surface)", style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
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",
maxHeight: "90vh",
}}
> >
{/* header */} {errors.name?.type === "manual" && (
<div style={{ <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
display: "flex", alignItems: "center", justifyContent: "space-between", )}
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", {/* name */}
background: "var(--color-surface-muted)", <Input
}}> label="اسم الفرع *"
<div> {...register("name")}
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
{isNew ? "إضافة فرع" : "تعديل فرع"} placeholder="فرع الرياض"
</p> autoComplete="organization"
<h2 id="branch-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> autoFocus
{isNew ? "فرع جديد" : editBranch?.name} dir="rtl"
</h2> />
</div>
<button type="button" onClick={onClose} aria-label="إغلاق" {/* email + phone */}
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" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
× <Input
</button> label="البريد الإلكتروني"
type="email"
{...register("email")}
error={errors.email?.message}
placeholder="branch@co.sa"
autoComplete="email"
dir="ltr"
/>
<Input
label="رقم الهاتف"
type="tel"
{...register("phone")}
error={errors.phone?.message}
placeholder="+966 5x xxx xxxx"
autoComplete="tel"
dir="ltr"
/>
</div> </div>
{/* body */} {/* city + street */}
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />} <Input
label="المدينة *"
{...register("city")}
error={errors.city?.message}
placeholder="الرياض"
dir="rtl"
/>
<Input
label="الشارع *"
{...register("street")}
error={errors.street?.message}
placeholder="شارع الملك فهد"
dir="rtl"
/>
</div>
{/* name */} {/* state + district */}
<label style={S.label}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
اسم الفرع * <Input
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="فرع الرياض" autoComplete="organization" dir="rtl" /> label="المنطقة"
{errors.name && <span style={S.errorText}>{errors.name}</span>} {...register("state")}
</label> error={errors.state?.message}
placeholder="منطقة الرياض"
dir="rtl"
/>
<Input
label="الحي"
{...register("district")}
error={errors.district?.message}
placeholder="حي العليا"
dir="rtl"
/>
</div>
{/* email + phone */} {/* buildingNo + unitNo + zipCode */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <Input
البريد الإلكتروني label="رقم المبنى"
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="branch@co.sa" autoComplete="email" dir="ltr" /> {...register("buildingNo")}
{errors.email && <span style={S.errorText}>{errors.email}</span>} error={errors.buildingNo?.message}
</label> placeholder="1234"
<label style={S.label}> dir="ltr"
رقم الهاتف />
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" /> <Input
{errors.phone && <span style={S.errorText}>{errors.phone}</span>} label="رقم الوحدة"
</label> {...register("unitNo")}
</div> error={errors.unitNo?.message}
placeholder="5"
dir="ltr"
/>
<Input
label="الرمز البريدي"
{...register("zipCode")}
error={errors.zipCode?.message}
placeholder="12345"
dir="ltr"
/>
</div>
{/* city + street */} {/* country */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <Input
<label style={S.label}> label="الدولة"
المدينة * {...register("country")}
<input style={inputStyle("city")} value={form.city} onChange={set("city")} placeholder="الرياض" dir="rtl" /> error={errors.country?.message}
{errors.city && <span style={S.errorText}>{errors.city}</span>} placeholder="SA"
</label> dir="ltr"
<label style={S.label}> />
الشارع *
<input style={inputStyle("street")} value={form.street} onChange={set("street")} placeholder="شارع الملك فهد" dir="rtl" />
{errors.street && <span style={S.errorText}>{errors.street}</span>}
</label>
</div>
{/* state + district */} {/* latitude + longitude */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <Input
المنطقة label="خط العرض (اختياري)"
<input style={inputStyle("state")} value={form.state} onChange={set("state")} placeholder="منطقة الرياض" dir="rtl" /> {...register("latitude")}
{errors.state && <span style={S.errorText}>{errors.state}</span>} error={errors.latitude?.message}
</label> placeholder="24.7136"
<label style={S.label}> dir="ltr"
الحي inputMode="decimal"
<input style={inputStyle("district")} value={form.district} onChange={set("district")} placeholder="حي العليا" dir="rtl" /> />
{errors.district && <span style={S.errorText}>{errors.district}</span>} <Input
</label> label="خط الطول (اختياري)"
</div> {...register("longitude")}
error={errors.longitude?.message}
{/* buildingNo + unitNo + zipCode */} placeholder="46.6753"
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}> dir="ltr"
<label style={S.label}> inputMode="decimal"
رقم المبنى />
<input style={inputStyle("buildingNo")} value={form.buildingNo} onChange={set("buildingNo")} placeholder="1234" dir="ltr" /> </div>
{errors.buildingNo && <span style={S.errorText}>{errors.buildingNo}</span>} </form>
</label> </Modal>
<label style={S.label}>
رقم الوحدة
<input style={inputStyle("unitNo")} value={form.unitNo} onChange={set("unitNo")} placeholder="5" dir="ltr" />
{errors.unitNo && <span style={S.errorText}>{errors.unitNo}</span>}
</label>
<label style={S.label}>
الرمز البريدي
<input style={inputStyle("zipCode")} value={form.zipCode} onChange={set("zipCode")} placeholder="12345" dir="ltr" />
{errors.zipCode && <span style={S.errorText}>{errors.zipCode}</span>}
</label>
</div>
{/* country */}
<label style={S.label}>
الدولة
<input style={inputStyle("country")} value={form.country} onChange={set("country")} placeholder="SA" dir="ltr" />
{errors.country && <span style={S.errorText}>{errors.country}</span>}
</label>
{/* latitude + longitude */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
خط العرض (اختياري)
<input style={inputStyle("latitude")} value={form.latitude} onChange={set("latitude")} placeholder="24.7136" dir="ltr" inputMode="decimal" />
{errors.latitude && <span style={S.errorText}>{errors.latitude}</span>}
</label>
<label style={S.label}>
خط الطول (اختياري)
<input style={inputStyle("longitude")} value={form.longitude} onChange={set("longitude")} placeholder="46.6753" dir="ltr" inputMode="decimal" />
{errors.longitude && <span style={S.errorText}>{errors.longitude}</span>}
</label>
</div>
{/* actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button type="button" onClick={onClose} disabled={saving}
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="submit" disabled={saving}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة الفرع" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form";
import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal } from "../UI";
import { import {
createMaintenanceSchema, createMaintenanceSchema,
updateMaintenanceSchema, updateMaintenanceSchema,
@@ -10,48 +10,16 @@ import {
import type { import type {
CarMaintenance, CarMaintenance,
CreateMaintenancePayload, CreateMaintenancePayload,
MaintenanceFormErrors,
UpdateMaintenancePayload, UpdateMaintenancePayload,
} from "@/src/types/carMaintanance"; } from "@/src/types/carMaintanance";
// ── Shared input style ──────────────────────────────────────────────────────── const FORM_ID = "maintenance-form";
const inputBase: React.CSSProperties = {
width: "100%",
height: 40,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
};
const labelStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
// ── Number coercion helper ──────────────────────────────────────────────────── // ── Number coercion helper ────────────────────────────────────────────────────
// The backend can return `cost` as a numeric string (common with Decimal // The backend can return `cost` as a numeric string (common with Decimal
// columns getting JSON-serialized as strings), even though our TS type says // columns getting JSON-serialized as strings), even though our TS type says
// `number`. If that untouched value round-trips back out on update without // `number`. Coerce defensively both when hydrating the form AND right before
// ever passing through the `<input type="number">` onChange handler, it // building the submit payload, so this can never slip through as a string.
// stays a string and fails the backend's strict `z.number()` check. Coerce
// defensively both when hydrating the form AND right before building the
// submit payload, so this can never happen regardless of the source.
function toNumberOrUndefined(v: unknown): number | undefined { function toNumberOrUndefined(v: unknown): number | undefined {
if (v === undefined || v === null || v === "") return undefined; if (v === undefined || v === null || v === "") return undefined;
@@ -59,30 +27,6 @@ function toNumberOrUndefined(v: unknown): number | undefined {
return Number.isNaN(n) ? undefined : n; return Number.isNaN(n) ? undefined : n;
} }
// ── yup validation ────────────────────────────────────────────────────────────
// Checks the form values against the right schema and turns any problems
// into a simple field -> message map the inputs below can read from.
async function validate(
form: Partial<CreateMaintenancePayload>,
isNew: boolean,
): Promise<MaintenanceFormErrors> {
const schema = isNew ? createMaintenanceSchema : updateMaintenanceSchema;
try {
await schema.validate(form, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<MaintenanceFormErrors>((acc, e) => {
const field = e.path as keyof MaintenanceFormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
}
// ── Date helper ─────────────────────────────────────────────────────────────── // ── Date helper ───────────────────────────────────────────────────────────────
// <input type="date"> gives "YYYY-MM-DD"; the backend expects full ISO-8601. // <input type="date"> gives "YYYY-MM-DD"; the backend expects full ISO-8601.
@@ -92,6 +36,19 @@ function toIsoDateTime(val: string): string {
return `${val}T00:00:00.000Z`; return `${val}T00:00:00.000Z`;
} }
// ── Form values shape ────────────────────────────────────────────────────────
// `cost` stays number | undefined (via setValueAs) so an empty input maps to
// `undefined` rather than NaN; startAt/endAt stay as raw "YYYY-MM-DD" strings
// while typed — yup's dateString test accepts that format directly — and are
// only converted to full ISO-8601 right before the payload is built.
interface MaintenanceFormValues {
reason: string;
cost: number | undefined;
startAt: string;
endAt: string;
}
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
interface CarMaintenanceFormModalProps { interface CarMaintenanceFormModalProps {
@@ -116,208 +73,121 @@ export function CarMaintenanceFormModal({
}: CarMaintenanceFormModalProps) { }: CarMaintenanceFormModalProps) {
const isNew = editRecord === null; const isNew = editRecord === null;
// ── Form state ───────────────────────────────────────────────────────────── const {
const [reason, setReason] = useState(editRecord?.reason ?? ""); register,
// Coerced defensively: editRecord.cost may arrive as a numeric string from handleSubmit,
// the backend (e.g. a Decimal column serialized to JSON as "150"). setError,
const [cost, setCost] = useState<number | undefined>(toNumberOrUndefined(editRecord?.cost)); formState: { errors, isSubmitting },
const [startAt, setStartAt] = useState(editRecord?.startAt?.slice(0, 10) ?? ""); } = useForm<MaintenanceFormValues>({
const [endAt, setEndAt] = useState(editRecord?.endAt?.slice(0, 10) ?? ""); // Cast the schema itself and pin the generic explicitly — create/update
// schemas differ structurally in which fields are required, so yup can't
const [errors, setErrors] = useState<MaintenanceFormErrors>({}); // unify them into MaintenanceFormValues on its own.
const [saving, setSaving] = useState(false); resolver: yupResolver<MaintenanceFormValues>(
const [apiError, setApiError] = useState(""); (isNew ? createMaintenanceSchema : updateMaintenanceSchema) as any,
const firstRef = useRef<HTMLInputElement>(null); ),
defaultValues: {
useEffect(() => { firstRef.current?.focus(); }, []); reason: editRecord?.reason ?? "",
useEffect(() => { // Coerced defensively: editRecord.cost may arrive as a numeric string
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; // from the backend (e.g. a Decimal column serialized to JSON as "150").
window.addEventListener("keydown", h); cost: toNumberOrUndefined(editRecord?.cost),
return () => window.removeEventListener("keydown", h); startAt: editRecord?.startAt?.slice(0, 10) ?? "",
}, [onClose]); endAt: editRecord?.endAt?.slice(0, 10) ?? "",
},
const parseNum = (v: string): number | undefined =>
v.trim() === "" ? undefined : Number(v);
const inputStyle = (field: keyof MaintenanceFormErrors): React.CSSProperties => ({
...inputBase,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
}); });
const clearFieldError = (field: keyof MaintenanceFormErrors) => const costField = register("cost", {
setErrors((p) => ({ ...p, [field]: undefined })); setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)),
});
// ── Submit ──────────────────────────────────────────────────────────────── // ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => { const submitHandler = async (data: MaintenanceFormValues) => {
e.preventDefault();
// Belt-and-braces: re-coerce cost right before it's used, in case it // Belt-and-braces: re-coerce cost right before it's used, in case it
// somehow slipped back into a string (e.g. untouched value hydrated // somehow slipped back into a string.
// from a record whose `cost` came back as a numeric string). const safeCost = toNumberOrUndefined(data.cost);
const safeCost = toNumberOrUndefined(cost);
// Build a snapshot for yup — include everything so optional rules also run. // Dates go out as full ISO-8601 strings, and cost always goes out as a
const formSnapshot: Partial<CreateMaintenancePayload> = { // real number, never a string.
reason, const raw: Record<string, unknown> = { reason: data.reason, cost: safeCost };
cost: safeCost, if (data.startAt) raw.startAt = toIsoDateTime(data.startAt);
...(startAt && { startAt }), if (data.endAt) raw.endAt = toIsoDateTime(data.endAt);
...(endAt && { endAt }),
};
const errs = await validate(formSnapshot, isNew);
if (Object.keys(errs).length) {
setErrors(errs);
return;
}
// Build the final payload — dates go out as full ISO-8601 strings, and
// cost always goes out as a real number, never a string.
const raw: Record<string, unknown> = { reason, cost: safeCost };
if (startAt) raw.startAt = toIsoDateTime(startAt);
if (endAt) raw.endAt = toIsoDateTime(endAt);
const payload = raw as unknown as CreateMaintenancePayload; const payload = raw as unknown as CreateMaintenancePayload;
setSaving(true);
setApiError("");
const ok = await onSubmit(payload, isNew); const ok = await onSubmit(payload, isNew);
setSaving(false); if (ok) {
if (ok) onClose(); onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); } else {
setError("reason", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
}
}; };
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" onClose={onClose}
aria-labelledby="maintenance-modal-title" size="sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} subtitle={isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"}
style={{ title={carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
position: "fixed", inset: 0, zIndex: 50, footer={
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", <>
display: "flex", alignItems: "center", justifyContent: "center", <Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
padding: "1rem", overflowY: "auto", إلغاء
}} </Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
{isNew ? "إضافة السجل" : "حفظ التغييرات"}
</Button>
</>
}
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", maxWidth: 520, noValidate
background: "var(--color-surface)", dir="rtl"
borderRadius: "var(--radius-2xl)", className="flex flex-col gap-4"
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
margin: "auto",
}}
> >
{/* Header */} {errors.reason?.type === "manual" && (
<div style={{ <Alert type="error" message={errors.reason.message ?? ""} onClose={() => {}} />
display: "flex", alignItems: "center", justifyContent: "space-between", )}
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", <Input
background: "var(--color-surface-muted)", label="سبب الصيانة *"
}}> {...register("reason")}
<div> error={errors.reason && errors.reason.type !== "manual" ? errors.reason.message : undefined}
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> placeholder="تغيير زيت المحرك"
{isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"} dir="rtl"
</p> autoFocus
<h2 id="maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> />
{carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
</h2> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
</div> <Input
<button type="button" onClick={onClose} aria-label="إغلاق" 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" }}> type="number"
× min={0}
</button> {...costField}
error={errors.cost?.message}
placeholder="0"
dir="ltr"
/>
<Input
label="تاريخ البدء *"
type="date"
{...register("startAt")}
error={errors.startAt?.message}
/>
</div> </div>
{/* Form */} <Input
<form label="تاريخ الانتهاء"
onSubmit={handleSubmit} type="date"
noValidate {...register("endAt")}
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }} error={errors.endAt?.message}
dir="rtl" hint="اتركه فارغاً إذا كانت الصيانة ما زالت جارية."
> />
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />} </form>
</Modal>
<label style={labelStyle}>
سبب الصيانة *
<input
ref={firstRef}
style={inputStyle("reason")}
value={reason}
onChange={(e) => { setReason(e.target.value); clearFieldError("reason"); }}
placeholder="تغيير زيت المحرك"
dir="rtl"
/>
{errors.reason && <span style={errorTextStyle}>{errors.reason}</span>}
</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
التكلفة (ر.س) *
<input
style={inputStyle("cost")}
type="number"
min={0}
value={cost ?? ""}
onChange={(e) => { setCost(parseNum(e.target.value)); clearFieldError("cost"); }}
placeholder="0"
dir="ltr"
/>
{errors.cost && <span style={errorTextStyle}>{errors.cost}</span>}
</label>
<label style={labelStyle}>
تاريخ البدء *
<input
style={inputStyle("startAt")}
type="date"
value={startAt}
onChange={(e) => { setStartAt(e.target.value); clearFieldError("startAt"); }}
/>
{errors.startAt && <span style={errorTextStyle}>{errors.startAt}</span>}
</label>
</div>
<label style={labelStyle}>
تاريخ الانتهاء
<input
style={inputStyle("endAt")}
type="date"
value={endAt}
onChange={(e) => { setEndAt(e.target.value); clearFieldError("endAt"); }}
/>
{errors.endAt && <span style={errorTextStyle}>{errors.endAt}</span>}
<span style={{ fontSize: 11, color: "var(--color-text-hint)", fontWeight: 400 }}>
اتركه فارغاً إذا كانت الصيانة ما زالت جارية.
</span>
</label>
{/* Actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button"
onClick={onClose}
disabled={saving}
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}
>
إلغاء
</button>
<button
type="submit"
disabled={saving}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}
>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة السجل" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -1,9 +1,8 @@
"use client"; "use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { import {
createClientSchema, createClientSchema,
updateClientSchema, updateClientSchema,
@@ -11,39 +10,7 @@ import {
} from "@/src/validations/client.validator"; } from "@/src/validations/client.validator";
import type { Client, ClientFormData } from "@/src/types/client"; import type { Client, ClientFormData } from "@/src/types/client";
// ── Styles ───────────────────────────────────────────────────────────────── const FORM_ID = "client-form";
const S = {
input: {
width: "100%",
height: 40,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
} as React.CSSProperties,
label: {
display: "flex",
flexDirection: "column" as const,
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
} as React.CSSProperties,
errorText: {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
} as React.CSSProperties,
};
const withError = (hasError: boolean): React.CSSProperties => ({
...S.input,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
// ── Props ────────────────────────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────────────────────────
interface ClientFormModalProps { interface ClientFormModalProps {
@@ -59,7 +26,6 @@ interface ClientFormModalProps {
export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) { export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) {
const isNew = editClient === null; const isNew = editClient === null;
// FIX 1: was `Schema` (capital S) — now correctly `schema`
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -76,12 +42,6 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
}, },
}); });
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const submitHandler = async (data: ClientFormData) => { const submitHandler = async (data: ClientFormData) => {
const ok = await onSubmit(data, isNew); const ok = await onSubmit(data, isNew);
if (ok) { if (ok) {
@@ -92,255 +52,91 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
}; };
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" onClose={onClose}
aria-labelledby="client-modal-title" size="sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} subtitle={isNew ? "إضافة عميل" : "تعديل عميل"}
style={{ title={isNew ? "عميل جديد" : editClient?.name ?? ""}
position: "fixed", footer={
inset: 0, <>
zIndex: 50, <Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
background: "rgba(15,23,42,0.55)", إلغاء
backdropFilter: "blur(4px)", </Button>
display: "flex", <Button type="submit" form={FORM_ID} loading={isSubmitting}>
alignItems: "center", {isNew ? "إضافة العميل" : "حفظ التغييرات"}
justifyContent: "center", </Button>
padding: "1rem", </>
}} }
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", noValidate
maxWidth: 520, dir="rtl"
background: "var(--color-surface)", className="flex flex-col gap-4"
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
}}
> >
{/* Header */} {errors.name?.type === "manual" && (
<div <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
style={{ )}
display: "flex",
alignItems: "center", <Input
justifyContent: "space-between", label={`اسم العميل ${isNew ? "*" : ""}`}
padding: "1.25rem 1.5rem", placeholder="شركة لوجي فلو للتوصيل"
borderBottom: "1px solid var(--color-border)", autoComplete="organization"
background: "var(--color-surface-muted)", dir="rtl"
}} error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
> {...register("name")}
<div> />
<p
style={{ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
fontSize: 11, <Input
letterSpacing: "0.3em", label="البريد الإلكتروني"
textTransform: "uppercase", type="email"
color: "#2563EB", placeholder="info@company.sa"
fontWeight: 600, autoComplete="email"
margin: 0, dir="ltr"
}} error={errors.email?.message}
> {...register("email")}
{isNew ? "إضافة عميل" : "تعديل عميل"} />
</p>
<h2 <Input
id="client-modal-title" label={`رقم الهاتف ${isNew ? "*" : ""}`}
style={{ type="tel"
fontSize: 17, placeholder="05xxxxxxxx"
fontWeight: 700, autoComplete="tel"
color: "var(--color-text-primary)", dir="ltr"
margin: "4px 0 0", error={errors.phone?.message}
}} {...register("phone")}
> />
{isNew ? "عميل جديد" : editClient?.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>
</div> </div>
{/* Body */} <Select
<form label="نوع العميل"
onSubmit={handleSubmit(submitHandler)} dir="rtl"
noValidate error={errors.clientType?.message}
style={{ {...register("clientType")}
padding: "1.5rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
> >
{errors.name?.type === "manual" && ( <option value="">اختر النوع</option>
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} /> {CLIENT_TYPES.map((t) => (
)} <option key={t} value={t}>
{t === "Individual" ? "فرد" : "شركة"}
</option>
))}
</Select>
<label style={S.label}> {!isNew && (
اسم العميل {isNew && "*"} <label className="flex items-center gap-2 text-[12px] font-semibold text-[var(--color-text-secondary)] cursor-pointer">
<input <input
{...register("name")} type="checkbox"
style={withError(!!errors.name)} {...register("isActive" as never)}
placeholder="شركة لوجي فلو للتوصيل" defaultChecked={editClient?.isActive ?? true}
autoComplete="organization" className="w-3.5 h-3.5 cursor-pointer"
dir="rtl"
/> />
{errors.name && errors.name.type !== "manual" && ( عميل نشط
<span style={S.errorText}>{errors.name.message}</span>
)}
</label> </label>
)}
<div </form>
style={{ </Modal>
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={S.label}>
البريد الإلكتروني
<input
{...register("email")}
style={withError(!!errors.email)}
type="email"
placeholder="info@company.sa"
autoComplete="email"
dir="ltr"
/>
{errors.email && (
<span style={S.errorText}>{errors.email.message}</span>
)}
</label>
<label style={S.label}>
رقم الهاتف {isNew && "*"}
<input
{...register("phone")}
style={withError(!!errors.phone)}
type="tel"
placeholder="05xxxxxxxx"
autoComplete="tel"
dir="ltr"
/>
{errors.phone && (
<span style={S.errorText}>{errors.phone.message}</span>
)}
</label>
</div>
<label style={S.label}>
نوع العميل
<select
{...register("clientType")}
style={{ ...withError(!!errors.clientType), cursor: "pointer" }}
dir="rtl"
>
<option value="">اختر النوع</option>
{CLIENT_TYPES.map((t) => (
<option key={t} value={t}>
{t === "Individual" ? "فرد" : "شركة"}
</option>
))}
</select>
{errors.clientType && (
<span style={S.errorText}>{errors.clientType.message}</span>
)}
</label>
{!isNew && (
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer",
}}
>
<input
type="checkbox"
{...register("isActive" as never)}
defaultChecked={editClient?.isActive ?? true}
style={{ width: 14, height: 14, cursor: "pointer" }}
/>
عميل نشط
</label>
)}
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
paddingTop: "0.5rem",
}}
>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting
? "var(--color-brand-400)"
: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Button } from "../../UI";
import { ArchivedClientTable } from "./ArchivedClientTable"; import { ArchivedClientTable } from "./ArchivedClientTable";
import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal"; import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal";
import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients"; import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients";
@@ -63,36 +63,33 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
</span> </span>
</h2> </h2>
</div> </div>
<button type="button" onClick={onClose} aria-label="إغلاق" <Button
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" }}> type="button"
variant="secondary"
onClick={onClose}
aria-label="إغلاق"
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
>
× ×
</button> </Button>
</div> </div>
{/* body */} {/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}> <div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */} {/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text" type="text"
placeholder="بحث بالاسم أو البريد الإلكتروني..." placeholder="بحث بالاسم أو البريد الإلكتروني..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
style={{ icon={
width: "100%", height: 40, <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
paddingRight: 36, paddingLeft: 12, <circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
borderRadius: "var(--radius-lg)", </svg>
border: "1px solid var(--color-border)", }
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>

View File

@@ -1,9 +1,8 @@
"use client"; "use client";
import { useEffect } from "react";
import { useForm, type Resolver } from "react-hook-form"; import { useForm, type Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal } from "../UI";
import { import {
createAddressSchema, createAddressSchema,
updateAddressSchema, updateAddressSchema,
@@ -13,32 +12,9 @@ import {
import type { ClientAddress } from "@/src/types/client_adresses"; import type { ClientAddress } from "@/src/types/client_adresses";
// ── Styles ───────────────────────────────────────────────────────────────── // ── Styles ─────────────────────────────────────────────────────────────────
// Only section-title styling is left here — every input/label/error visual
// is now owned by the shared <Input /> component.
const S = { const S = {
input: {
width: "100%",
height: 40,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
} as React.CSSProperties,
label: {
display: "flex",
flexDirection: "column" as const,
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
} as React.CSSProperties,
errorText: {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
} as React.CSSProperties,
sectionTitle: { sectionTitle: {
fontSize: 11, fontSize: 11,
fontWeight: 700, fontWeight: 700,
@@ -50,11 +26,7 @@ const S = {
} as React.CSSProperties, } as React.CSSProperties,
}; };
const withError = (hasError: boolean): React.CSSProperties => ({ const FORM_ID = "address-form";
...S.input,
border: hasError ? "1px solid var(--color-danger)" : "1px solid var(--color-border)",
background: hasError ? "#FEF2F2" : S.input.background,
});
// ── Props ────────────────────────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────────────────────────
interface AddressFormModalProps { interface AddressFormModalProps {
@@ -138,12 +110,6 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
location: locationErr = {}, location: locationErr = {},
} = errors as AddressErrors; } = errors as AddressErrors;
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => { const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => {
const ok = await onSubmit(data); const ok = await onSubmit(data);
if (ok) { if (ok) {
@@ -154,260 +120,178 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
}; };
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" title={isNew ? "عنوان جديد" : editAddress?.label ?? ""}
aria-labelledby="addr-modal-title" subtitle={isNew ? "إضافة عنوان" : "تعديل عنوان"}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} onClose={onClose}
style={{ size="md"
position: "fixed", footer={
inset: 0, <>
zIndex: 50, <Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
background: "rgba(15,23,42,0.55)", إلغاء
backdropFilter: "blur(4px)", </Button>
display: "flex", <Button type="submit" form={FORM_ID} loading={isSubmitting}>
alignItems: "center", {isNew ? "إضافة العنوان" : "حفظ التغييرات"}
justifyContent: "center", </Button>
padding: "1rem", </>
}} }
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", noValidate
maxWidth: 560, style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
maxHeight: "90vh",
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 */} {errors.label?.type === "manual" && (
<div <Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
style={{ )}
flexShrink: 0,
display: "flex", {/* Address Meta */}
alignItems: "center", <p style={S.sectionTitle}>بيانات العنوان</p>
justifyContent: "space-between",
padding: "1.25rem 1.5rem", <Input
borderBottom: "1px solid var(--color-border)", label={`نوع العنوان${isNew ? " *" : ""}`}
background: "var(--color-surface-muted)", {...register("label")}
}} error={errors.label && errors.label.type !== "manual" ? errors.label.message : undefined}
> placeholder="مثال: منزل / مكتب / شحن"
<div> dir="rtl"
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> />
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
</p> <Input
<h2 label="اسم الفرع (اختياري)"
id="addr-modal-title" {...register("branchName")}
style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }} placeholder="فرع الرياض"
> dir="rtl"
{isNew ? "عنوان جديد" : editAddress?.label} />
</h2>
</div> {/* Address Details */}
<button <p style={S.sectionTitle}>تفاصيل العنوان</p>
type="button"
onClick={onClose} <Input
aria-label="إغلاق" label={`الشارع / العنوان التفصيلي${isNew ? " *" : ""}`}
style={{ {...register("details.street")}
width: 34, error={detailsErr.street?.message}
height: 34, placeholder="شارع الملك فهد، مبنى 12"
borderRadius: "var(--radius-md)", dir="rtl"
border: "1px solid var(--color-border)", />
background: "var(--color-surface)",
cursor: "pointer", <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
fontSize: 18, <Input
color: "var(--color-text-muted)", label={`المدينة${isNew ? " *" : ""}`}
display: "flex", {...register("details.city")}
alignItems: "center", error={detailsErr.city?.message}
justifyContent: "center", placeholder="الرياض"
}} dir="rtl"
> />
× <Input
</button> label="المنطقة"
{...register("details.state")}
error={detailsErr.state?.message}
placeholder="منطقة الرياض"
dir="rtl"
/>
</div> </div>
{/* Scrollable body */} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<div style={{ overflowY: "auto", flex: 1 }}> <Input
<form label="الحي (اختياري)"
onSubmit={handleSubmit(submitHandler)} {...register("details.district")}
noValidate placeholder="حي العليا"
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }} dir="rtl"
> />
{errors.label?.type === "manual" && ( <Input
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} /> label="رقم المبنى (اختياري)"
)} {...register("details.buildingNo")}
placeholder="1234"
{/* Address Meta */} dir="ltr"
<p style={S.sectionTitle}>بيانات العنوان</p> />
<label style={S.label}>
نوع العنوان
<input
{...register("label")}
style={withError(!!errors.label && errors.label.type !== "manual")}
placeholder="مثال: منزل / مكتب / شحن"
dir="rtl"
/>
{errors.label && errors.label.type !== "manual" && (
<span style={S.errorText}>{errors.label.message}</span>
)}
</label>
<label style={S.label}>
اسم الفرع (اختياري)
<input {...register("branchName")} style={S.input} placeholder="فرع الرياض" dir="rtl" />
</label>
{/* Address Details */}
<p style={S.sectionTitle}>تفاصيل العنوان</p>
<label style={S.label}>
الشارع / العنوان التفصيلي {isNew && "*"}
<input
{...register("details.street")}
style={withError(!!detailsErr.street)}
placeholder="شارع الملك فهد، مبنى 12"
dir="rtl"
/>
{detailsErr.street && <span style={S.errorText}>{detailsErr.street.message}</span>}
</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
المدينة {isNew && "*"}
<input {...register("details.city")} style={withError(!!detailsErr.city)} placeholder="الرياض" dir="rtl" />
{detailsErr.city && <span style={S.errorText}>{detailsErr.city.message}</span>}
</label>
<label style={S.label}>
المنطقة
<input {...register("details.state")} style={withError(!!detailsErr.state)} placeholder="منطقة الرياض" dir="rtl" />
{detailsErr.state && <span style={S.errorText}>{detailsErr.state.message}</span>}
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
الحي (اختياري)
<input {...register("details.district")} style={S.input} placeholder="حي العليا" dir="rtl" />
</label>
<label style={S.label}>
رقم المبنى (اختياري)
<input {...register("details.buildingNo")} style={S.input} placeholder="1234" dir="ltr" />
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
رقم الوحدة (اختياري)
<input {...register("details.unitNo")} style={S.input} placeholder="5678" dir="ltr" />
</label>
<label style={S.label}>
الرقم الإضافي (اختياري)
<input {...register("details.additionalNo")} style={S.input} placeholder="0000" dir="ltr" />
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
الرمز البريدي (اختياري)
<input {...register("details.zipCode")} style={withError(!!detailsErr.zipCode)} placeholder="11564" dir="ltr" />
{detailsErr.zipCode && <span style={S.errorText}>{detailsErr.zipCode.message}</span>}
</label>
<label style={S.label}>
الدولة
<input {...register("details.country")} style={withError(!!detailsErr.country)} placeholder="SA" dir="ltr" />
{detailsErr.country && <span style={S.errorText}>{detailsErr.country.message}</span>}
</label>
</div>
<label style={S.label}>
الشقة / الطابق (اختياري)
<input {...register("details.apartment")} style={S.input} placeholder="الطابق الثالث" dir="rtl" />
</label>
{/* Contact Person */}
<p style={S.sectionTitle}>جهة الاتصال (اختياري)</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
الاسم
<input {...register("contactPerson.name")} style={withError(!!contactErr.name)} placeholder="أحمد محمد" dir="rtl" />
{contactErr.name && <span style={S.errorText}>{contactErr.name.message}</span>}
</label>
<label style={S.label}>
رقم الهاتف
<input {...register("contactPerson.phone")} style={withError(!!contactErr.phone)} type="tel" placeholder="05xxxxxxxx" dir="ltr" />
{contactErr.phone && <span style={S.errorText}>{contactErr.phone.message}</span>}
</label>
</div>
{/* Coordinates */}
<p style={S.sectionTitle}>الإحداثيات الجغرافية {isNew && "*"}</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
خط الطول (Longitude) {isNew && "*"}
<input {...register("location.coordinates.0" as never)} style={withError(!!locationErr.coordinates)} type="number" step="any" placeholder="46.6753" dir="ltr" />
</label>
<label style={S.label}>
خط العرض (Latitude) {isNew && "*"}
<input {...register("location.coordinates.1" as never)} style={withError(!!locationErr.coordinates)} type="number" step="any" placeholder="24.7136" dir="ltr" />
{locationErr.coordinates && <span style={S.errorText}>{locationErr.coordinates.message}</span>}
</label>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
</button>
</div>
</form>
</div> </div>
</div>
</div> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="رقم الوحدة (اختياري)"
{...register("details.unitNo")}
placeholder="5678"
dir="ltr"
/>
<Input
label="الرقم الإضافي (اختياري)"
{...register("details.additionalNo")}
placeholder="0000"
dir="ltr"
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="الرمز البريدي (اختياري)"
{...register("details.zipCode")}
error={detailsErr.zipCode?.message}
placeholder="11564"
dir="ltr"
/>
<Input
label="الدولة"
{...register("details.country")}
error={detailsErr.country?.message}
placeholder="SA"
dir="ltr"
/>
</div>
<Input
label="الشقة / الطابق (اختياري)"
{...register("details.apartment")}
placeholder="الطابق الثالث"
dir="rtl"
/>
{/* Contact Person */}
<p style={S.sectionTitle}>جهة الاتصال (اختياري)</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="الاسم"
{...register("contactPerson.name")}
error={contactErr.name?.message}
placeholder="أحمد محمد"
dir="rtl"
/>
<Input
label="رقم الهاتف"
{...register("contactPerson.phone")}
error={contactErr.phone?.message}
type="tel"
placeholder="05xxxxxxxx"
dir="ltr"
/>
</div>
{/* Coordinates */}
<p style={S.sectionTitle}>الإحداثيات الجغرافية{isNew ? " *" : ""}</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label={`خط الطول (Longitude)${isNew ? " *" : ""}`}
{...register("location.coordinates.0" as never)}
error={locationErr.coordinates?.message}
type="number"
step="any"
placeholder="46.6753"
dir="ltr"
/>
<Input
label={`خط العرض (Latitude)${isNew ? " *" : ""}`}
{...register("location.coordinates.1" as never)}
error={locationErr.coordinates?.message}
type="number"
step="any"
placeholder="24.7136"
dir="ltr"
/>
</div>
</form>
</Modal>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Button } from "../../UI";
import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable"; import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable";
import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal"; import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal";
import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses"; import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses";
@@ -67,36 +67,33 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
</span> </span>
</h2> </h2>
</div> </div>
<button type="button" onClick={onClose} aria-label="إغلاق" <Button
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" }}> type="button"
variant="secondary"
onClick={onClose}
aria-label="إغلاق"
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
>
× ×
</button> </Button>
</div> </div>
{/* body */} {/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}> <div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search — client-side only: this endpoint has no server-side search/pagination */} {/* search — client-side only: this endpoint has no server-side search/pagination */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text" type="text"
placeholder="بحث بالنوع أو المدينة أو الفرع..." placeholder="بحث بالنوع أو المدينة أو الفرع..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
style={{ icon={
width: "100%", height: 40, <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
paddingRight: 36, paddingLeft: 12, <circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
borderRadius: "var(--radius-lg)", </svg>
border: "1px solid var(--color-border)", }
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, FileInput, Input, Modal, Select } from "../UI";
import { get } from "@/src/services/api"; import { get } from "@/src/services/api";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator"; import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator";
@@ -16,37 +16,8 @@ import type {
DriverStatus, DriverStatus,
} from "@/src/types/driver"; } from "@/src/types/driver";
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
import { FileInput } from "@/src/Components/Driver/DriverFormModalHelper";
// ── Shared input style ─────────────────────────────────────────────────────── // ── Shared bits ──────────────────────────────────────────────────────────────
const inputBase: React.CSSProperties = {
width: "100%",
height: 40,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
};
const labelStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
const sectionHeadingStyle: React.CSSProperties = { const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
@@ -57,12 +28,7 @@ const sectionHeadingStyle: React.CSSProperties = {
margin: "0.5rem 0 0", margin: "0.5rem 0 0",
}; };
const optionalLabelStyle: React.CSSProperties = { const FORM_ID = "driver-form";
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
marginRight: 4,
};
// ── Date helper ────────────────────────────────────────────────────────────── // ── Date helper ──────────────────────────────────────────────────────────────
@@ -144,9 +110,12 @@ export function DriverFormModal({
handleSubmit, handleSubmit,
control, control,
setError, setError,
setFocus,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<DriverFormValues>({ } = useForm<DriverFormValues>({
resolver: yupResolver(isNew ? createDriverSchema : updateDriverSchema) as never, // Cast the schema itself — the create/update schemas have structurally
// different required fields, so yup can't unify them into one type.
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any),
defaultValues: { defaultValues: {
name: editDriver?.name ?? "", name: editDriver?.name ?? "",
phone: editDriver?.phone ?? "", phone: editDriver?.phone ?? "",
@@ -174,23 +143,12 @@ export function DriverFormModal({
}); });
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
const firstRef = useRef<HTMLInputElement>(null);
// register("name") already attaches its own ref — focusing via setFocus
// avoids the ref collision that a separate `ref={firstRef}` would cause.
useEffect(() => { useEffect(() => {
firstRef.current?.focus(); setFocus("name");
}, []); }, [setFocus]);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const withError = (hasError: boolean): React.CSSProperties => ({
...inputBase,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
// ── Submit ──────────────────────────────────────────────────────────────── // ── Submit ────────────────────────────────────────────────────────────────
@@ -240,377 +198,250 @@ export function DriverFormModal({
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" onClose={onClose}
aria-labelledby="driver-modal-title" size="lg"
onClick={(e) => { subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
if (e.target === e.currentTarget) onClose(); title={isNew ? "سائق جديد" : editDriver?.name ?? ""}
}} footer={
style={{ <>
position: "fixed", inset: 0, zIndex: 50, <Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", إلغاء
display: "flex", alignItems: "center", justifyContent: "center", </Button>
padding: "1rem", overflowY: "auto", <Button type="submit" form={FORM_ID} loading={isSubmitting}>
}} {isNew ? "إضافة السائق" : "حفظ التغييرات"}
</Button>
</>
}
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", maxWidth: 640, noValidate
background: "var(--color-surface)", dir="rtl"
borderRadius: "var(--radius-2xl)", className="flex flex-col gap-4"
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden", margin: "auto",
}}
> >
{/* Header */} {errors.name?.type === "manual" && (
<div style={{ <Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} />
display: "flex", alignItems: "center", justifyContent: "space-between", )}
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", {/* ── Section: Personal Info ── */}
background: "var(--color-surface-muted)", <p style={sectionHeadingStyle}>البيانات الشخصية</p>
}}>
<div> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> <Input
{isNew ? "إضافة سائق" : "تعديل سائق"} label="الاسم الكامل *"
</p> placeholder="محمد عبدالله"
<h2 id="driver-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> dir="rtl"
{isNew ? "سائق جديد" : editDriver?.name} autoComplete="off"
</h2> error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
</div> {...register("name")}
<button type="button" onClick={onClose} aria-label="إغلاق" />
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)", <Input
border: "1px solid var(--color-border)", background: "var(--color-surface)", label="رقم الجوال *"
cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", placeholder="05XXXXXXXX"
display: "flex", alignItems: "center", justifyContent: "center", dir="ltr"
}}> error={errors.phone?.message}
× {...register("phone")}
</button> />
<Input
label="البريد الإلكتروني (اختياري)"
type="email"
placeholder="example@mail.com"
dir="ltr"
error={errors.email?.message}
{...register("email")}
/>
<Input
label="الجنسية (اختياري)"
placeholder="سعودي"
dir="rtl"
error={errors.nationality?.message}
{...register("nationality")}
/>
<Input
className="sm:col-span-2"
label="العنوان (اختياري)"
placeholder="الرياض، حي..."
dir="rtl"
error={errors.address?.message}
{...register("address")}
/>
</div> </div>
{/* Form */} {/* ── Section: ID & GOSI ── */}
<form <p style={sectionHeadingStyle}>الهوية والتأمينات</p>
onSubmit={handleSubmit(submitHandler)}
noValidate <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
style={{ <Select
padding: "1.5rem", label="نوع الهوية (اختياري)"
display: "flex", flexDirection: "column", gap: "1rem", dir="rtl"
maxHeight: "75vh", overflowY: "auto", error={errors.nationalIdType?.message}
}} {...register("nationalIdType")}
dir="rtl" >
> <option value="">اختر النوع</option>
{errors.name?.type === "manual" && ( <option value="NationalID">هوية وطنية</option>
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} /> <option value="Iqama">إقامة</option>
<option value="Passport">جواز سفر</option>
</Select>
<Input
label="رقم الهوية (اختياري)"
placeholder="1XXXXXXXXX"
dir="ltr"
error={errors.nationalId?.message}
{...register("nationalId")}
/>
<Input
label="انتهاء الهوية (اختياري)"
type="date"
error={errors.nationalIdExpiry?.message}
{...register("nationalIdExpiry")}
/>
<Input
label="رقم GOSI (اختياري)"
placeholder="GOSI-XXXX"
dir="ltr"
error={errors.gosiNumber?.message}
{...register("gosiNumber")}
/>
</div>
{/* ── Section: License ── */}
<p style={sectionHeadingStyle}>رخصة القيادة</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Input
label="رقم الرخصة (اختياري)"
placeholder="LIC-XXXX"
dir="ltr"
error={errors.licenseNumber?.message}
{...register("licenseNumber")}
/>
<Input
label="نوع الرخصة (اختياري)"
placeholder="خاص / عام"
dir="rtl"
error={errors.licenseType?.message}
{...register("licenseType")}
/>
<Input
label="انتهاء الرخصة (اختياري)"
type="date"
error={errors.licenseExpiry?.message}
{...register("licenseExpiry")}
/>
</div>
{/* ── Section: Driver Card ── */}
<p style={sectionHeadingStyle}>بطاقة السائق</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Input
label="رقم البطاقة (اختياري)"
placeholder="CARD-XXXX"
dir="ltr"
error={errors.driverCardNumber?.message}
{...register("driverCardNumber")}
/>
<Select
label="نوع البطاقة (اختياري)"
dir="rtl"
error={errors.driverCardType?.message}
{...register("driverCardType")}
>
<option value="">اختر النوع</option>
<option value="Temporary">مؤقتة</option>
<option value="Seasonal">موسمية</option>
<option value="Annual">سنوية</option>
<option value="Restricted">مقيدة</option>
</Select>
<Input
label="انتهاء البطاقة (اختياري)"
type="date"
error={errors.driverCardExpiry?.message}
{...register("driverCardExpiry")}
/>
</div>
{/* ── Section: Operational ── */}
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Select
label="الفرع (اختياري)"
dir="rtl"
error={errors.branchId?.message}
{...register("branchId")}
>
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</Select>
<Input
label="نوع السائق (اختياري)"
placeholder="رئيسي / احتياطي"
dir="rtl"
error={errors.driverType?.message}
{...register("driverType")}
/>
{/* Status — edit only */}
{!isNew && (
<Select label="الحالة" dir="rtl" {...register("status")}>
<option value="Active">نشط</option>
<option value="InTrip">في رحلة</option>
<option value="Inactive">غير نشط</option>
<option value="Suspended">موقوف</option>
</Select>
)} )}
</div>
{/* ── Section: Personal Info ── */} {/* ── Section: Photos ── */}
<p style={sectionHeadingStyle}>البيانات الشخصية</p> <p style={sectionHeadingStyle}>الصور والمستندات</p>
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}>
جميع حقول الصور اختيارية
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* Name — required */} {/* File inputs cannot use register() directly — bind via Controller
<label style={labelStyle}> to the existing FileInput component's current/onChange props. */}
الاسم الكامل * <Controller
<input name="photo"
ref={firstRef} control={control}
style={withError(!!errors.name && errors.name.type !== "manual")} render={({ field }) => (
{...register("name")} <FileInput label="صورة السائق" current={field.value} onChange={field.onChange} />
placeholder="محمد عبدالله"
dir="rtl"
autoComplete="off"
/>
{errors.name && errors.name.type !== "manual" && (
<span style={errorTextStyle}>{errors.name.message}</span>
)}
</label>
{/* Phone — required */}
<label style={labelStyle}>
رقم الجوال *
<input
style={withError(!!errors.phone)}
{...register("phone")}
placeholder="05XXXXXXXX"
dir="ltr"
/>
{errors.phone && <span style={errorTextStyle}>{errors.phone.message}</span>}
</label>
{/* Email — optional */}
<label style={labelStyle}>
البريد الإلكتروني
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.email)}
type="email"
{...register("email")}
placeholder="example@mail.com"
dir="ltr"
/>
{errors.email && <span style={errorTextStyle}>{errors.email.message}</span>}
</label>
{/* Nationality — optional */}
<label style={labelStyle}>
الجنسية
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.nationality)}
{...register("nationality")}
placeholder="سعودي"
dir="rtl"
/>
{errors.nationality && <span style={errorTextStyle}>{errors.nationality.message}</span>}
</label>
{/* Address — optional, full width */}
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
العنوان
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.address)}
{...register("address")}
placeholder="الرياض، حي..."
dir="rtl"
/>
{errors.address && <span style={errorTextStyle}>{errors.address.message}</span>}
</label>
</div>
{/* ── Section: ID & GOSI ── */}
<p style={sectionHeadingStyle}>الهوية والتأمينات</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* National ID Type — optional */}
<label style={labelStyle}>
نوع الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...withError(!!errors.nationalIdType), cursor: "pointer" }}
{...register("nationalIdType")}
dir="rtl"
>
<option value="">اختر النوع</option>
<option value="NationalID">هوية وطنية</option>
<option value="Iqama">إقامة</option>
<option value="Passport">جواز سفر</option>
</select>
{errors.nationalIdType && <span style={errorTextStyle}>{errors.nationalIdType.message}</span>}
</label>
{/* National ID number */}
<label style={labelStyle}>
رقم الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("nationalId")} placeholder="1XXXXXXXXX" dir="ltr" />
</label>
{/* National ID expiry */}
<label style={labelStyle}>
انتهاء الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} type="date" {...register("nationalIdExpiry")} />
</label>
{/* GOSI — optional */}
<label style={labelStyle}>
رقم GOSI
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("gosiNumber")} placeholder="GOSI-XXXX" dir="ltr" />
</label>
</div>
{/* ── Section: License ── */}
<p style={sectionHeadingStyle}>رخصة القيادة</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* License number — optional */}
<label style={labelStyle}>
رقم الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.licenseNumber)}
{...register("licenseNumber")}
placeholder="LIC-XXXX"
dir="ltr"
/>
{errors.licenseNumber && <span style={errorTextStyle}>{errors.licenseNumber.message}</span>}
</label>
{/* License type — optional */}
<label style={labelStyle}>
نوع الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("licenseType")} placeholder="خاص / عام" dir="rtl" />
</label>
{/* License expiry — optional, validated if provided */}
<label style={labelStyle}>
انتهاء الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={withError(!!errors.licenseExpiry)} type="date" {...register("licenseExpiry")} />
{errors.licenseExpiry && <span style={errorTextStyle}>{errors.licenseExpiry.message}</span>}
</label>
</div>
{/* ── Section: Driver Card ── */}
<p style={sectionHeadingStyle}>بطاقة السائق</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* Card number — optional */}
<label style={labelStyle}>
رقم البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("driverCardNumber")} placeholder="CARD-XXXX" dir="ltr" />
</label>
{/* Card type — optional */}
<label style={labelStyle}>
نوع البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...withError(!!errors.driverCardType), cursor: "pointer" }}
{...register("driverCardType")}
dir="rtl"
>
<option value="">اختر النوع</option>
<option value="Temporary">مؤقتة</option>
<option value="Seasonal">موسمية</option>
<option value="Annual">سنوية</option>
<option value="Restricted">مقيدة</option>
</select>
{errors.driverCardType && <span style={errorTextStyle}>{errors.driverCardType.message}</span>}
</label>
{/* Card expiry — optional, validated if provided */}
<label style={labelStyle}>
انتهاء البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={withError(!!errors.driverCardExpiry)}
type="date"
{...register("driverCardExpiry")}
/>
{errors.driverCardExpiry && (
<span style={errorTextStyle}>{errors.driverCardExpiry.message}</span>
)}
</label>
</div>
{/* ── Section: Operational ── */}
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* Branch — optional */}
<label style={labelStyle}>
الفرع
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...withError(!!errors.branchId), cursor: "pointer" }}
{...register("branchId")}
dir="rtl"
>
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
{errors.branchId && <span style={errorTextStyle}>{errors.branchId.message}</span>}
</label>
{/* Driver type — optional */}
<label style={labelStyle}>
نوع السائق
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("driverType")} placeholder="رئيسي / احتياطي" dir="rtl" />
</label>
{/* Status — edit only */}
{!isNew && (
<label style={labelStyle}>
الحالة
<select style={{ ...inputBase, cursor: "pointer" }} {...register("status")} dir="rtl">
<option value="Active">نشط</option>
<option value="InTrip">في رحلة</option>
<option value="Inactive">غير نشط</option>
<option value="Suspended">موقوف</option>
</select>
</label>
)} )}
</div> />
<Controller
{/* ── Section: Photos ── */} name="nationalPhoto"
<p style={sectionHeadingStyle}>الصور والمستندات</p> control={control}
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}> render={({ field }) => (
جميع حقول الصور اختيارية <FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} />
</p> )}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}> <Controller
{/* File inputs cannot use register() directly — bind via Controller name="driverCardPhoto"
to the existing FileInput component's current/onChange props. */} control={control}
<Controller render={({ field }) => (
name="photo" <FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} />
control={control} )}
render={({ field }) => ( />
<FileInput label="صورة السائق" current={field.value} onChange={field.onChange} /> </div>
)} </form>
/> </Modal>
<Controller
name="nationalPhoto"
control={control}
render={({ field }) => (
<FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} />
)}
/>
<Controller
name="driverCardPhoto"
control={control}
render={({ field }) => (
<FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} />
)}
/>
</div>
{/* ── Actions ── */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
cursor: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة السائق" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedDriverTable } from "./ArchivedDriverTable"; import { ArchivedDriverTable } from "./ArchivedDriverTable";
import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal"; import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal";
import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers"; import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers";
@@ -10,6 +10,21 @@ interface ArchivedDriversProps {
onClose: () => void; onClose: () => void;
} }
const searchIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);
/** /**
* Top-level modal for browsing archived drivers. Wires the * Top-level modal for browsing archived drivers. Wires the
* useArchivedDrivers hook to the table and detail modal, mirroring * useArchivedDrivers hook to the table and detail modal, mirroring
@@ -25,79 +40,28 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
} = useArchivedDrivers(); } = useArchivedDrivers();
return ( return (
<div <>
role="dialog" aria-modal="true" aria-labelledby="archive-driver-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 70,
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
>
{viewDriverId && ( {viewDriverId && (
<ArchivedDriverDetailModal driverId={viewDriverId} onClose={() => setViewDriverId(null)} /> <ArchivedDriverDetailModal driverId={viewDriverId} onClose={() => setViewDriverId(null)} />
)} )}
<div <Modal
onClick={e => e.stopPropagation()} open
style={{ onClose={onClose}
width: "100%", maxWidth: 920, size="lg"
background: "var(--color-surface-sunken)", subtitle="الأرشيف"
borderRadius: "var(--radius-xl)", title={`السائقون المؤرشفون (${total})`}
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
> >
{/* header */} <div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-driver-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
السائقون المؤرشفون
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */} {/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف..." placeholder="بحث بالاسم أو الهاتف..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
icon={searchIcon}
dir="rtl" dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>
@@ -113,7 +77,7 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
onPageChange={setPage} onPageChange={setPage}
/> />
</div> </div>
</div> </Modal>
</div> </>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Spinner } from "../UI"; import { Alert, Button, Input } from "../UI";
import { driverService } from "@/src/services/driver.service"; import { driverService } from "@/src/services/driver.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
@@ -65,20 +65,8 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
}} }}
> >
<div style={{ flex: 1, minWidth: 140 }}> <div style={{ flex: 1, minWidth: 140 }}>
<label <Input
htmlFor="report-date" label="تاريخ التقرير"
style={{
display: "block",
fontSize: 11,
fontWeight: 600,
color: "var(--color-text-muted)",
marginBottom: 4,
}}
>
تاريخ التقرير
</label>
<input
id="report-date"
type="date" type="date"
value={date} value={date}
max={today} max={today}
@@ -86,64 +74,21 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
setDate(e.target.value); setDate(e.target.value);
setError(null); setError(null);
}} }}
style={{
width: "100%",
height: 38,
padding: "0 0.625rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
}}
/> />
</div> </div>
<button <Button onClick={handleGenerate} disabled={!date} loading={loading}>
type="button"
onClick={handleGenerate}
disabled={loading || !date}
style={{
height: 38,
padding: "0 1rem",
borderRadius: "var(--radius-md)",
border: "none",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
background: !date || loading
? "var(--color-brand-400)"
: "var(--color-brand-600)",
cursor: !date || loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
whiteSpace: "nowrap",
}}
>
{loading && <Spinner size="sm" className="text-white" />}
{loading ? "جارٍ الإنشاء…" : "إنشاء التقرير"} {loading ? "جارٍ الإنشاء…" : "إنشاء التقرير"}
</button> </Button>
</div> </div>
{error && ( {error && (
<div <Alert
style={{ type="error"
marginTop: "0.75rem", message={error}
borderRadius: "var(--radius-md)", onClose={() => setError(null)}
background: "#FEF2F2", className="mt-3"
border: "1px solid #FECACA", />
padding: "0.625rem 0.875rem",
fontSize: 12,
color: "#DC2626",
fontWeight: 500,
}}
>
{error}
</div>
)} )}
</div> </div>
); );

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { clientService } from "@/src/services/client.service"; import { clientService } from "@/src/services/client.service";
import { tripService } from "@/src/services/trip.service"; import { tripService } from "@/src/services/trip.service";
@@ -22,34 +22,6 @@ import type {
import type { Client } from "@/src/types/client"; import type { Client } from "@/src/types/client";
import type { Trip } from "@/src/types/trip"; import type { Trip } from "@/src/types/trip";
const inputBase: React.CSSProperties = {
width: "100%",
height: 40,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
};
const labelStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
const sectionHeadingStyle: React.CSSProperties = { const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
letterSpacing: "0.25em", letterSpacing: "0.25em",
@@ -59,19 +31,12 @@ const sectionHeadingStyle: React.CSSProperties = {
margin: "0.5rem 0 0", margin: "0.5rem 0 0",
}; };
const optionalLabelStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
marginRight: 4,
};
const createLinkStyle: React.CSSProperties = { const createLinkStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
color: "var(--color-brand-600)", color: "var(--color-brand-600)",
textDecoration: "none", textDecoration: "none",
fontWeight: 500, fontWeight: 500,
marginTop: 2, marginTop: 6,
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 3, gap: 3,
@@ -147,9 +112,12 @@ export function OrderFormModal({
register, register,
handleSubmit, handleSubmit,
setError, setError,
setFocus,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<OrderFormValues>({ } = useForm<OrderFormValues>({
resolver: yupResolver(isNew ? createOrderSchema : updateOrderSchema) as never, // Cast the schema itself — create/update schemas differ structurally in
// which fields are required, so yup can't unify them into one type.
resolver: yupResolver<OrderFormValues>((isNew ? createOrderSchema : updateOrderSchema) as any),
defaultValues: { defaultValues: {
shipmentNumber: editOrder?.shipmentNumber ?? "", shipmentNumber: editOrder?.shipmentNumber ?? "",
recipientName: editOrder?.recipientName ?? "", recipientName: editOrder?.recipientName ?? "",
@@ -200,7 +168,12 @@ export function OrderFormModal({
}); });
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
const firstRef = useRef<HTMLInputElement>(null);
// register("shipmentNumber") already attaches its own ref — focusing via
// setFocus avoids the ref collision a separate `ref={firstRef}` would cause.
useEffect(() => {
setFocus("shipmentNumber");
}, [setFocus]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -227,9 +200,6 @@ export function OrderFormModal({
}; };
}, []); }, []);
useEffect(() => {
firstRef.current?.focus();
}, []);
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -238,11 +208,6 @@ export function OrderFormModal({
return () => window.removeEventListener("keydown", h); return () => window.removeEventListener("keydown", h);
}, [onClose]); }, [onClose]);
const withError = (hasError: boolean): React.CSSProperties => ({
...inputBase,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
const numberField = ( const numberField = (
field: field:
| "quantity" | "quantity"
@@ -340,438 +305,371 @@ export function OrderFormModal({
const pickupErr = errors.pickupAddress; const pickupErr = errors.pickupAddress;
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" title={isNew ? "طلب جديد" : editOrder?.shipmentNumber ?? ""}
aria-labelledby="order-modal-title" subtitle={isNew ? "إنشاء طلب" : "تعديل طلب"}
onClick={(e) => { onClose={onClose}
if (e.target === e.currentTarget) onClose(); size="lg"
}} footer={
style={{ <>
position: "fixed", <Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
inset: 0, إلغاء
zIndex: 50, </Button>
background: "rgba(15,23,42,0.55)", <Button type="submit" form="order-form" loading={isSubmitting}>
backdropFilter: "blur(4px)", {isNew ? "إنشاء الطلب" : "حفظ التغييرات"}
display: "flex", </Button>
alignItems: "center", </>
justifyContent: "center", }
padding: "1rem",
overflowY: "auto",
}}
> >
<div <form
onClick={(e) => e.stopPropagation()} id="order-form"
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", noValidate
maxWidth: 640, style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
background: "var(--color-surface)", dir="rtl"
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
margin: "auto",
}}
> >
<div {errors.shipmentNumber?.type === "manual" && (
style={{ <Alert type="error" message={errors.shipmentNumber.message ?? ""} onClose={() => setApiError("")} />
display: "flex", )}
alignItems: "center",
justifyContent: "space-between", <p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
background: "var(--color-surface-muted)", <Input
}} id="order-shipmentNumber"
> label="رقم الشحنة *"
error={
errors.shipmentNumber && errors.shipmentNumber.type !== "manual"
? errors.shipmentNumber.message
: undefined
}
{...register("shipmentNumber")}
placeholder="SHP-0001"
dir="ltr"
autoComplete="off"
/>
<div> <div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> <Select
{isNew ? "إنشاء طلب" : "تعديل طلب"} id="order-clientId"
</p> label="العميل *"
<h2 error={errors.clientId?.message}
id="order-modal-title" disabled={relLoading}
style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0", fontFamily: "var(--font-mono)" }} {...register("clientId")}
dir="rtl"
style={{ cursor: relLoading ? "wait" : "pointer" }}
> >
{isNew ? "طلب جديد" : editOrder?.shipmentNumber} <option value="">{relLoading ? "جارٍ التحميل…" : "اختر العميل"}</option>
</h2> {clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.phone ? `${c.phone}` : ""}
</option>
))}
</Select>
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء عميل جديد
</Link>
</div>
<Input
id="order-recipientName"
label="اسم المستلم *"
error={errors.recipientName?.message}
{...register("recipientName")}
placeholder="أحمد محمد"
dir="rtl"
/>
<Input
id="order-recipientPhone"
label="رقم جوال المستلم *"
error={errors.recipientPhone?.message}
{...register("recipientPhone")}
placeholder="05XXXXXXXX"
dir="ltr"
/>
<div style={{ gridColumn: "1 / -1" }}>
<Select
id="order-tripId"
label={isNew ? "الرحلة *" : "الرحلة (اختياري)"}
error={errors.tripId?.message}
disabled={relLoading}
{...register("tripId")}
dir="rtl"
style={{ cursor: relLoading ? "wait" : "pointer" }}
>
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر الرحلة"}</option>
{trips.map((t) => (
<option key={t.id} value={t.id}>
{t.tripNumber}
{t.title ? `${t.title}` : ""}
{t.driver?.name ? ` (${t.driver.name})` : ""}
</option>
))}
</Select>
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء رحلة جديدة
</Link>
</div> </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>
</div> </div>
<form <p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
onSubmit={handleSubmit(submitHandler)}
noValidate <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }} <Input
dir="rtl" id="order-type"
> label="نوع الشحنة (اختياري)"
{errors.shipmentNumber?.type === "manual" && ( {...register("type")}
<Alert type="error" message={errors.shipmentNumber.message ?? ""} onClose={() => setApiError("")} /> placeholder="عادي / مبرد"
dir="rtl"
/>
<Input
id="order-quantity"
label="الكمية *"
type="number"
min={1}
error={errors.quantity?.message}
{...numberField("quantity")}
dir="ltr"
/>
<Input
id="order-weight"
label="الوزن (كجم) (اختياري)"
type="number"
min={0}
step="0.01"
{...numberField("weight")}
dir="ltr"
/>
</div>
<p style={sectionHeadingStyle}>بيانات الدفع</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<Input
id="order-subTotal"
label="الإجمالي الفرعي (اختياري)"
type="number"
min={0}
step="0.01"
error={errors.subTotal?.message}
{...numberField("subTotal")}
dir="ltr"
/>
<Input
id="order-vatRate"
label="نسبة الضريبة (%) (اختياري)"
type="number"
min={0}
step="0.01"
error={errors.vatRate?.message}
{...numberField("vatRate")}
dir="ltr"
/>
<Select
id="order-paymentMethod"
label="طريقة الدفع (اختياري)"
{...register("paymentMethod")}
dir="rtl"
style={{ cursor: "pointer" }}
>
<option value="">اختر الطريقة</option>
<option value="Cash">نقداً</option>
<option value="Card">بطاقة</option>
<option value="Prepaid">مدفوع مسبقاً</option>
</Select>
{!isNew && (
<Select
id="order-paymentStatus"
label="حالة الدفع"
{...register("paymentStatus")}
dir="rtl"
style={{ cursor: "pointer" }}
>
<option value=""></option>
<option value="Pending">معلَّق</option>
<option value="Paid">مدفوع</option>
<option value="Failed">فشل</option>
<option value="Refunded">مُسترجع</option>
</Select>
)} )}
</div>
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p> <p style={sectionHeadingStyle}>عنوان التسليم</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<Input
id="delivery-city"
label="المدينة (اختياري)"
{...register("deliveryAddress.details.city")}
placeholder="الرياض"
dir="rtl"
/>
<Input
id="delivery-district"
label="الحي (اختياري)"
{...register("deliveryAddress.details.district")}
placeholder="العليا"
dir="rtl"
/>
<Input
id="delivery-street"
label="الشارع (اختياري)"
{...register("deliveryAddress.details.street")}
placeholder="شارع الأمير محمد"
dir="rtl"
/>
<Input
id="delivery-buildingNo"
label="رقم المبنى (اختياري)"
{...register("deliveryAddress.details.buildingNo")}
placeholder="1234"
dir="ltr"
/>
<Input
id="delivery-unitNo"
label="رقم الوحدة (اختياري)"
{...register("deliveryAddress.details.unitNo")}
placeholder="56"
dir="ltr"
/>
<Input
id="delivery-zipCode"
label="الرمز البريدي (اختياري)"
{...register("deliveryAddress.details.zipCode")}
placeholder="12345"
dir="ltr"
/>
<Input
id="delivery-lng"
label="خط الطول (Longitude) *"
type="number"
step="any"
error={deliveryErr?.location?.coordinates ? " " : undefined}
{...coordField("deliveryAddress.location.coordinates.0")}
placeholder="46.6753"
dir="ltr"
/>
<Input
id="delivery-lat"
label="خط العرض (Latitude) *"
type="number"
step="any"
error={deliveryErr?.location?.coordinates?.message}
{...coordField("deliveryAddress.location.coordinates.1")}
placeholder="24.7136"
dir="ltr"
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <p style={sectionHeadingStyle}>عنوان الاستلام</p>
<label style={labelStyle}>
رقم الشحنة *
<input
ref={firstRef}
style={withError(!!errors.shipmentNumber && errors.shipmentNumber.type !== "manual")}
{...register("shipmentNumber")}
placeholder="SHP-0001"
dir="ltr"
autoComplete="off"
/>
{errors.shipmentNumber && errors.shipmentNumber.type !== "manual" && (
<span style={errorTextStyle}>{errors.shipmentNumber.message}</span>
)}
</label>
<label style={labelStyle}> <div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}>
العميل * {(["id", "new"] as const).map((m) => (
<select <Button
style={{ ...withError(!!errors.clientId), cursor: relLoading ? "wait" : "pointer" }} key={m}
disabled={relLoading}
{...register("clientId")}
dir="rtl"
>
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر العميل"}</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.phone ? `${c.phone}` : ""}
</option>
))}
</select>
{errors.clientId && <span style={errorTextStyle}>{errors.clientId.message}</span>}
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء عميل جديد
</Link>
</label>
<label style={labelStyle}>
اسم المستلم *
<input style={withError(!!errors.recipientName)} {...register("recipientName")} placeholder="أحمد محمد" dir="rtl" />
{errors.recipientName && <span style={errorTextStyle}>{errors.recipientName.message}</span>}
</label>
<label style={labelStyle}>
رقم جوال المستلم *
<input style={withError(!!errors.recipientPhone)} {...register("recipientPhone")} placeholder="05XXXXXXXX" dir="ltr" />
{errors.recipientPhone && <span style={errorTextStyle}>{errors.recipientPhone.message}</span>}
</label>
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
{isNew ? "الرحلة *" : "الرحلة"}
{!isNew && <span style={optionalLabelStyle}>(اختياري)</span>}
<select
style={{ ...withError(!!errors.tripId), cursor: relLoading ? "wait" : "pointer" }}
disabled={relLoading}
{...register("tripId")}
dir="rtl"
>
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر الرحلة"}</option>
{trips.map((t) => (
<option key={t.id} value={t.id}>
{t.tripNumber}
{t.title ? `${t.title}` : ""}
{t.driver?.name ? ` (${t.driver.name})` : ""}
</option>
))}
</select>
{errors.tripId && <span style={errorTextStyle}>{errors.tripId.message}</span>}
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء رحلة جديدة
</Link>
</label>
</div>
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
نوع الشحنة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("type")} placeholder="عادي / مبرد" dir="rtl" />
</label>
<label style={labelStyle}>
الكمية *
<input type="number" min={1} style={withError(!!errors.quantity)} {...numberField("quantity")} dir="ltr" />
{errors.quantity && <span style={errorTextStyle}>{errors.quantity.message}</span>}
</label>
<label style={labelStyle}>
الوزن (كجم)
<span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min={0} step="0.01" style={inputBase} {...numberField("weight")} dir="ltr" />
</label>
</div>
<p style={sectionHeadingStyle}>بيانات الدفع</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
الإجمالي الفرعي
<span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min={0} step="0.01" style={withError(!!errors.subTotal)} {...numberField("subTotal")} dir="ltr" />
{errors.subTotal && <span style={errorTextStyle}>{errors.subTotal.message}</span>}
</label>
<label style={labelStyle}>
نسبة الضريبة (%)
<span style={optionalLabelStyle}>(اختياري)</span>
<input type="number" min={0} step="0.01" style={withError(!!errors.vatRate)} {...numberField("vatRate")} dir="ltr" />
{errors.vatRate && <span style={errorTextStyle}>{errors.vatRate.message}</span>}
</label>
<label style={labelStyle}>
طريقة الدفع
<span style={optionalLabelStyle}>(اختياري)</span>
<select style={{ ...inputBase, cursor: "pointer" }} {...register("paymentMethod")} dir="rtl">
<option value="">اختر الطريقة</option>
<option value="Cash">نقداً</option>
<option value="Card">بطاقة</option>
<option value="Prepaid">مدفوع مسبقاً</option>
</select>
</label>
{!isNew && (
<label style={labelStyle}>
حالة الدفع
<select style={{ ...inputBase, cursor: "pointer" }} {...register("paymentStatus")} dir="rtl">
<option value=""></option>
<option value="Pending">معلَّق</option>
<option value="Paid">مدفوع</option>
<option value="Failed">فشل</option>
<option value="Refunded">مُسترجع</option>
</select>
</label>
)}
</div>
<p style={sectionHeadingStyle}>عنوان التسليم</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
المدينة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.city")} placeholder="الرياض" dir="rtl" />
</label>
<label style={labelStyle}>
الحي
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.district")} placeholder="العليا" dir="rtl" />
</label>
<label style={labelStyle}>
الشارع
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.street")} placeholder="شارع الأمير محمد" dir="rtl" />
</label>
<label style={labelStyle}>
رقم المبنى
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.buildingNo")} placeholder="1234" dir="ltr" />
</label>
<label style={labelStyle}>
رقم الوحدة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.unitNo")} placeholder="56" dir="ltr" />
</label>
<label style={labelStyle}>
الرمز البريدي
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("deliveryAddress.details.zipCode")} placeholder="12345" dir="ltr" />
</label>
<label style={labelStyle}>
خط الطول (Longitude) *
<input
type="number"
step="any"
style={withError(!!deliveryErr?.location?.coordinates)}
{...coordField("deliveryAddress.location.coordinates.0")}
placeholder="46.6753"
dir="ltr"
/>
</label>
<label style={labelStyle}>
خط العرض (Latitude) *
<input
type="number"
step="any"
style={withError(!!deliveryErr?.location?.coordinates)}
{...coordField("deliveryAddress.location.coordinates.1")}
placeholder="24.7136"
dir="ltr"
/>
{deliveryErr?.location?.coordinates && (
<span style={errorTextStyle}>{deliveryErr.location.coordinates.message}</span>
)}
</label>
</div>
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}>
{(["id", "new"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setPickupMode(m)}
style={{
height: 32,
padding: "0 0.875rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${pickupMode === m ? "var(--color-brand-600)" : "var(--color-border)"}`,
background: pickupMode === m ? "var(--color-brand-50, #EFF6FF)" : "var(--color-surface)",
fontSize: 12,
fontWeight: 600,
color: pickupMode === m ? "var(--color-brand-600)" : "var(--color-text-secondary)",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
{m === "id" ? "عنوان موجود (ID)" : "عنوان جديد"}
</button>
))}
</div>
{pickupMode === "id" ? (
<label style={labelStyle}>
معرّف العنوان (MongoDB ID)
<input
style={inputBase}
{...register("pickupAddressId")}
placeholder="67a1b2c3d4e5f6a7b8c9d0e1"
dir="ltr"
autoComplete="off"
/>
</label>
) : (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
المدينة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.city")} placeholder="جدة" dir="rtl" />
</label>
<label style={labelStyle}>
الحي
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.district")} placeholder="الروضة" dir="rtl" />
</label>
<label style={labelStyle}>
الشارع
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.street")} placeholder="شارع التحلية" dir="rtl" />
</label>
<label style={labelStyle}>
رقم المبنى
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.buildingNo")} placeholder="4321" dir="ltr" />
</label>
<label style={labelStyle}>
رقم الوحدة
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.unitNo")} placeholder="12" dir="ltr" />
</label>
<label style={labelStyle}>
الرمز البريدي
<span style={optionalLabelStyle}>(اختياري)</span>
<input style={inputBase} {...register("pickupAddress.details.zipCode")} placeholder="23456" dir="ltr" />
</label>
<label style={labelStyle}>
خط الطول (Longitude)
<span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
step="any"
style={withError(!!pickupErr?.location?.coordinates)}
{...coordField("pickupAddress.location.coordinates.0")}
placeholder="46.6753"
dir="ltr"
/>
</label>
<label style={labelStyle}>
خط العرض (Latitude)
<span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
step="any"
style={withError(!!pickupErr?.location?.coordinates)}
{...coordField("pickupAddress.location.coordinates.1")}
placeholder="24.7136"
dir="ltr"
/>
{pickupErr?.location?.coordinates && (
<span style={errorTextStyle}>{pickupErr.location.coordinates.message}</span>
)}
</label>
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button" type="button"
onClick={onClose} size="sm"
disabled={isSubmitting} variant={pickupMode === m ? "primary" : "secondary"}
style={{ onClick={() => setPickupMode(m)}
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
> >
إلغاء {m === "id" ? "عنوان موجود (ID)" : "عنوان جديد"}
</button> </Button>
<button ))}
type="submit" </div>
disabled={isSubmitting}
style={{ {pickupMode === "id" ? (
height: 40, <Input
padding: "0 1.5rem", id="pickup-addressId"
borderRadius: "var(--radius-md)", label="معرّف العنوان (MongoDB ID)"
border: "none", {...register("pickupAddressId")}
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)", placeholder="67a1b2c3d4e5f6a7b8c9d0e1"
fontSize: 13, dir="ltr"
fontWeight: 700, autoComplete="off"
color: "#FFF", />
cursor: isSubmitting ? "not-allowed" : "pointer", ) : (
display: "flex", <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
alignItems: "center", <Input
gap: 8, id="pickup-city"
fontFamily: "var(--font-sans)", label="المدينة (اختياري)"
}} {...register("pickupAddress.details.city")}
> placeholder="جدة"
{isSubmitting && <Spinner size="sm" className="text-white" />} dir="rtl"
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إنشاء الطلب" : "حفظ التغييرات"} />
</button> <Input
id="pickup-district"
label="الحي (اختياري)"
{...register("pickupAddress.details.district")}
placeholder="الروضة"
dir="rtl"
/>
<Input
id="pickup-street"
label="الشارع (اختياري)"
{...register("pickupAddress.details.street")}
placeholder="شارع التحلية"
dir="rtl"
/>
<Input
id="pickup-buildingNo"
label="رقم المبنى (اختياري)"
{...register("pickupAddress.details.buildingNo")}
placeholder="4321"
dir="ltr"
/>
<Input
id="pickup-unitNo"
label="رقم الوحدة (اختياري)"
{...register("pickupAddress.details.unitNo")}
placeholder="12"
dir="ltr"
/>
<Input
id="pickup-zipCode"
label="الرمز البريدي (اختياري)"
{...register("pickupAddress.details.zipCode")}
placeholder="23456"
dir="ltr"
/>
<Input
id="pickup-lng"
label="خط الطول (Longitude) (اختياري)"
type="number"
step="any"
error={pickupErr?.location?.coordinates ? " " : undefined}
{...coordField("pickupAddress.location.coordinates.0")}
placeholder="46.6753"
dir="ltr"
/>
<Input
id="pickup-lat"
label="خط العرض (Latitude) (اختياري)"
type="number"
step="any"
error={pickupErr?.location?.coordinates?.message}
{...coordField("pickupAddress.location.coordinates.1")}
placeholder="24.7136"
dir="ltr"
/>
</div> </div>
</form> )}
</div> </form>
</div> </Modal>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedOrderTable } from "./ArchivedOrderTable"; import { ArchivedOrderTable } from "./ArchivedOrderTable";
import { ArchivedOrderDetailModal } from "./ArchivedOrderDetailModal"; import { ArchivedOrderDetailModal } from "./ArchivedOrderDetailModal";
import { useArchivedOrders } from "@/src/hooks/archive/useArchivedOrders"; import { useArchivedOrders } from "@/src/hooks/archive/useArchivedOrders";
@@ -11,6 +11,21 @@ interface ArchivedOrdersModalProps {
onClose: () => void; onClose: () => void;
} }
const searchIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) { export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
const [viewOrder, setViewOrder] = useState<ArchivedOrder | null>(null); const [viewOrder, setViewOrder] = useState<ArchivedOrder | null>(null);
@@ -21,79 +36,28 @@ export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
} = useArchivedOrders(); } = useArchivedOrders();
return ( return (
<div <>
role="dialog" aria-modal="true" aria-labelledby="archive-orders-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 70,
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
>
{viewOrder && ( {viewOrder && (
<ArchivedOrderDetailModal order={viewOrder} onClose={() => setViewOrder(null)} /> <ArchivedOrderDetailModal order={viewOrder} onClose={() => setViewOrder(null)} />
)} )}
<div <Modal
onClick={e => e.stopPropagation()} open
style={{ onClose={onClose}
width: "100%", maxWidth: 920, size="lg"
background: "var(--color-surface-sunken)", subtitle="الأرشيف"
borderRadius: "var(--radius-xl)", title={`الطلبات المؤرشفة (${total})`}
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
> >
{/* header */} <div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-orders-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
الطلبات المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */} {/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="رقم الشحنة أو المستلم..." placeholder="رقم الشحنة أو المستلم..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
icon={searchIcon}
dir="rtl" dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>
@@ -109,7 +73,7 @@ export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
onPageChange={setPage} onPageChange={setPage}
/> />
</div> </div>
</div> </Modal>
</div> </>
); );
} }

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Select, Textarea, Spinner } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { import {
createTripSchema, createTripSchema,
@@ -22,34 +22,8 @@ import type { CarOption } from "@/src/types/car";
import type { BranchOption } from "@/src/types/branch"; import type { BranchOption } from "@/src/types/branch";
// ── Shared styles ──────────────────────────────────────────────────────────── // ── Shared styles ────────────────────────────────────────────────────────────
// Only for section headings — everything field-level now comes from the
const inputBase: React.CSSProperties = { // Input / Select / Textarea components themselves.
width: "100%",
height: 40,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
};
const labelStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
const sectionHeadingStyle: React.CSSProperties = { const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
@@ -60,13 +34,6 @@ const sectionHeadingStyle: React.CSSProperties = {
margin: "0.5rem 0 0", margin: "0.5rem 0 0",
}; };
const optionalLabelStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
marginRight: 4,
};
// ── Form values shape ──────────────────────────────────────────────────────── // ── Form values shape ────────────────────────────────────────────────────────
// startTime/endTime store the ISO-8601 string (via setValueAs on register), // startTime/endTime store the ISO-8601 string (via setValueAs on register),
// while the <input type="datetime-local"> DOM element itself keeps showing // while the <input type="datetime-local"> DOM element itself keeps showing
@@ -127,9 +94,12 @@ export function TripFormModal({
register, register,
handleSubmit, handleSubmit,
setError, setError,
setFocus,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<TripFormValues>({ } = useForm<TripFormValues>({
resolver: yupResolver(isNew ? createTripSchema : updateTripSchema) as never, // Cast the schema itself — create/update schemas differ structurally in
// which fields are required, so yup can't unify them into one type.
resolver: yupResolver<TripFormValues>((isNew ? createTripSchema : updateTripSchema) as any),
defaultValues: { defaultValues: {
title: editTrip?.title ?? "", title: editTrip?.title ?? "",
driverId: editTrip?.driverId ?? "", driverId: editTrip?.driverId ?? "",
@@ -150,11 +120,13 @@ export function TripFormModal({
}); });
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
const firstRef = useRef<HTMLInputElement>(null);
// register("title") already attaches its own ref — Input forwards it
// through to the underlying <input>, so setFocus works with no separate
// ref needed here.
useEffect(() => { useEffect(() => {
firstRef.current?.focus(); setFocus("title");
}, []); }, [setFocus]);
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -163,11 +135,6 @@ export function TripFormModal({
return () => window.removeEventListener("keydown", h); return () => window.removeEventListener("keydown", h);
}, [onClose]); }, [onClose]);
const withError = (hasError: boolean): React.CSSProperties => ({
...inputBase,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
const numberField = ( const numberField = (
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected", field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
) => ) =>
@@ -182,6 +149,9 @@ export function TripFormModal({
setValueAs: (v) => (v ? `${v}:00.000Z` : ""), setValueAs: (v) => (v ? `${v}:00.000Z` : ""),
}); });
const titleError =
errors.title && errors.title.type !== "manual" ? errors.title.message : undefined;
// ── Submit ──────────────────────────────────────────────────────────────── // ── Submit ────────────────────────────────────────────────────────────────
const submitHandler = async (data: TripFormValues) => { const submitHandler = async (data: TripFormValues) => {
@@ -273,22 +243,19 @@ export function TripFormModal({
> >
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"} {isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
</h2> </h2>
<button <Button
type="button" type="button"
variant="ghost"
size="sm"
onClick={onClose} onClick={onClose}
style={{ aria-label="إغلاق"
background: "none", className="!px-1"
border: "none",
cursor: "pointer",
color: "var(--color-text-muted)",
padding: 4,
}}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </Button>
</div> </div>
{/* Form */} {/* Form */}
@@ -306,212 +273,186 @@ export function TripFormModal({
<Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} /> <Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} />
)} )}
{/* ── Section:trip management── */} {/* ── Section: trip management ── */}
<p style={sectionHeadingStyle}>أساسيات الرحلة</p> <p style={sectionHeadingStyle}>أساسيات الرحلة</p>
{/* Title */} {/* Title */}
<label style={labelStyle}> <Input
عنوان الرحلة <span style={{ color: "var(--color-danger)" }}>*</span> label="عنوان الرحلة *"
<input error={titleError}
ref={firstRef} {...register("title")}
style={withError(!!errors.title && errors.title.type !== "manual")} placeholder="مثال: توزيع الرياض الشمالي"
{...register("title")} dir="rtl"
placeholder="مثال: توزيع الرياض الشمالي" />
dir="rtl"
/>
{errors.title && errors.title.type !== "manual" && (
<span style={errorTextStyle}>{errors.title.message}</span>
)}
</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{/* Driver */} {/* Driver */}
<label style={labelStyle}> <Select
السائق <span style={{ color: "var(--color-danger)" }}>*</span> label="السائق *"
<select style={{ ...withError(!!errors.driverId), cursor: "pointer" }} {...register("driverId")} dir="rtl"> error={errors.driverId?.message}
<option value="">اختر السائق</option> {...register("driverId")}
{drivers.map((d) => ( dir="rtl"
<option key={d.id} value={d.id}> >
{d.name} {d.phone} <option value="">اختر السائق</option>
</option> {drivers.map((d) => (
))} <option key={d.id} value={d.id}>
</select> {d.name} {d.phone}
{errors.driverId && <span style={errorTextStyle}>{errors.driverId.message}</span>} </option>
</label> ))}
</Select>
{/* Car */} {/* Car */}
<label style={labelStyle}> <Select
السيارة <span style={{ color: "var(--color-danger)" }}>*</span> label="السيارة *"
<select style={{ ...withError(!!errors.carId), cursor: "pointer" }} {...register("carId")} dir="rtl"> error={errors.carId?.message}
<option value="">اختر السيارة</option> {...register("carId")}
{cars.map((c) => ( dir="rtl"
<option key={c.id} value={c.id}> >
{c.manufacturer} {c.model} {c.plateNumber} <option value="">اختر السيارة</option>
</option> {cars.map((c) => (
))} <option key={c.id} value={c.id}>
</select> {c.manufacturer} {c.model} {c.plateNumber}
{errors.carId && <span style={errorTextStyle}>{errors.carId.message}</span>} </option>
</label> ))}
</Select>
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{/* Branch */} {/* Branch */}
<label style={labelStyle}> <Select
الفرع <span style={{ color: "var(--color-danger)" }}>*</span> label="الفرع *"
<select style={{ ...withError(!!errors.branchId), cursor: "pointer" }} {...register("branchId")} dir="rtl"> error={errors.branchId?.message}
<option value="">اختر الفرع</option> {...register("branchId")}
{branches.map((b) => ( dir="rtl"
<option key={b.id} value={b.id}>{b.name}</option> >
))} <option value="">اختر الفرع</option>
</select> {branches.map((b) => (
{errors.branchId && <span style={errorTextStyle}>{errors.branchId.message}</span>} <option key={b.id} value={b.id}>{b.name}</option>
</label> ))}
</Select>
{/* Status */} {/* Status */}
<label style={labelStyle}> <Select label="الحالة" {...register("status")} dir="rtl">
الحالة <option value="Scheduled">مجدولة</option>
<select style={{ ...inputBase, cursor: "pointer" }} {...register("status")} dir="rtl"> <option value="InProgress">جارية</option>
<option value="Scheduled">مجدولة</option> <option value="Completed">مكتملة</option>
<option value="InProgress">جارية</option> <option value="Cancelled">ملغاة</option>
<option value="Completed">مكتملة</option> </Select>
<option value="Cancelled">ملغاة</option>
</select>
</label>
</div> </div>
{/* ── Section: التوقيت ── */} {/* ── Section: التوقيت ── */}
<p style={sectionHeadingStyle}>التوقيت</p> <p style={sectionHeadingStyle}>التوقيت</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}> <Input
وقت البدء <span style={optionalLabelStyle}>(اختياري)</span> label="وقت البدء"
<input type="datetime-local" style={withError(!!errors.startTime)} {...dateTimeField("startTime")} /> hint="اختياري"
{errors.startTime && <span style={errorTextStyle}>{errors.startTime.message}</span>} type="datetime-local"
</label> error={errors.startTime?.message}
{...dateTimeField("startTime")}
<label style={labelStyle}> />
وقت الانتهاء <span style={optionalLabelStyle}>(اختياري)</span> <Input
<input type="datetime-local" style={withError(!!errors.endTime)} {...dateTimeField("endTime")} /> label="وقت الانتهاء"
{errors.endTime && <span style={errorTextStyle}>{errors.endTime.message}</span>} hint="اختياري"
</label> type="datetime-local"
error={errors.endTime?.message}
{...dateTimeField("endTime")}
/>
</div> </div>
{/* ── Section: الأعداد والمبالغ ── */} {/* ── Section: الأعداد والمبالغ ── */}
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p> <p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}> <Input
المجمّع <span style={optionalLabelStyle}>(اختياري)</span> label="المجمّع"
<input type="number" min="0" style={withError(!!errors.collectedCount)} {...numberField("collectedCount")} placeholder="0" dir="ltr" /> hint="اختياري"
{errors.collectedCount && <span style={errorTextStyle}>{errors.collectedCount.message}</span>} type="number"
</label> min="0"
error={errors.collectedCount?.message}
<label style={labelStyle}> {...numberField("collectedCount")}
المُسلَّم <span style={optionalLabelStyle}>(اختياري)</span> placeholder="0"
<input type="number" min="0" style={withError(!!errors.deliveredCount)} {...numberField("deliveredCount")} placeholder="0" dir="ltr" /> dir="ltr"
{errors.deliveredCount && <span style={errorTextStyle}>{errors.deliveredCount.message}</span>} />
</label> <Input
label="المُسلَّم"
<label style={labelStyle}> hint="اختياري"
المُرتجع <span style={optionalLabelStyle}>(اختياري)</span> type="number"
<input type="number" min="0" style={withError(!!errors.returnedCount)} {...numberField("returnedCount")} placeholder="0" dir="ltr" /> min="0"
{errors.returnedCount && <span style={errorTextStyle}>{errors.returnedCount.message}</span>} error={errors.deliveredCount?.message}
</label> {...numberField("deliveredCount")}
placeholder="0"
<label style={labelStyle}> dir="ltr"
النقد المحصّل <span style={optionalLabelStyle}>(اختياري)</span> />
<input type="number" min="0" step="0.01" style={withError(!!errors.totalCashCollected)} {...numberField("totalCashCollected")} placeholder="0.00" dir="ltr" /> <Input
{errors.totalCashCollected && <span style={errorTextStyle}>{errors.totalCashCollected.message}</span>} label="المُرتجع"
</label> hint="اختياري"
type="number"
min="0"
error={errors.returnedCount?.message}
{...numberField("returnedCount")}
placeholder="0"
dir="ltr"
/>
<Input
label="النقد المحصّل"
hint="اختياري"
type="number"
min="0"
step="0.01"
error={errors.totalCashCollected?.message}
{...numberField("totalCashCollected")}
placeholder="0.00"
dir="ltr"
/>
</div> </div>
{/* ── Section: ملاحظات ── */} {/* ── Section: ملاحظات ── */}
<p style={sectionHeadingStyle}>ملاحظات</p> <p style={sectionHeadingStyle}>ملاحظات</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}> <Textarea
ملاحظات <span style={optionalLabelStyle}>(اختياري)</span> label="ملاحظات"
<textarea hint="اختياري"
style={{ ...withError(!!errors.notes), height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }} error={errors.notes?.message}
{...register("notes")} {...register("notes")}
placeholder="أي ملاحظات إضافية…" placeholder="أي ملاحظات إضافية…"
dir="rtl" dir="rtl"
/> className="h-[72px]"
{errors.notes && <span style={errorTextStyle}>{errors.notes.message}</span>} />
</label> <Textarea
label="سبب الإنهاء"
<label style={labelStyle}> hint="اختياري"
سبب الإنهاء <span style={optionalLabelStyle}>(اختياري)</span> error={errors.endReason?.message}
<textarea {...register("endReason")}
style={{ ...withError(!!errors.endReason), height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }} placeholder="سبب إنهاء أو إلغاء الرحلة…"
{...register("endReason")} dir="rtl"
placeholder="سبب إنهاء أو إلغاء الرحلة…" className="h-[72px]"
dir="rtl" />
/>
{errors.endReason && <span style={errorTextStyle}>{errors.endReason.message}</span>}
</label>
</div> </div>
{/* reason — edit only (for reassigning driver/car) */} {/* reason — edit only (for reassigning driver/car) */}
{!isNew && ( {!isNew && (
<> <>
<p style={sectionHeadingStyle}>سجل التغيير</p> <p style={sectionHeadingStyle}>سجل التغيير</p>
<label style={labelStyle}> <Input
سبب التعديل{" "} label="سبب التعديل"
<span style={optionalLabelStyle}>(مطلوب عند تغيير السائق أو السيارة)</span> hint="مطلوب عند تغيير السائق أو السيارة"
<input {...register("reason")}
style={inputBase} placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
{...register("reason")} dir="rtl"
placeholder="مثال: تغيير السائق بسبب إجازة طارئة" />
dir="rtl"
/>
</label>
</> </>
)} )}
{/* ── Actions ── */} {/* ── Actions ── */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button <Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
type="button"
onClick={onClose}
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء إلغاء
</button> </Button>
<button <Button type="submit" variant="primary" loading={isSubmitting}>
type="submit"
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة الرحلة" : "حفظ التغييرات"} {isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
</button> </Button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Spinner, Alert, EmptyState } from "@/src/Components/UI"; import { Spinner, Alert, EmptyState, Input, Button } from "@/src/Components/UI";
import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips"; import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips";
import { TRIP_STATUS_MAP } from "@/src/types/trip"; import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { Trip } from "@/src/types/trip"; import type { Trip } from "@/src/types/trip";
@@ -11,6 +11,8 @@ interface ArchivedTripListProps {
} }
// ── Status badge — reuses the existing trip status color map ──────────────── // ── Status badge — reuses the existing trip status color map ────────────────
// (per-status colors come from TRIP_STATUS_MAP, so the shared Badge component
// — which only supports a fixed palette — doesn't cover this; kept custom.)
function TripStatusBadge({ status }: { status: Trip["status"] }) { function TripStatusBadge({ status }: { status: Trip["status"] }) {
const s = TRIP_STATUS_MAP[status]; const s = TRIP_STATUS_MAP[status];
return ( return (
@@ -30,6 +32,21 @@ function fmtDate(iso?: string | null): string {
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" }); return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
} }
const searchIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);
/** /**
* ArchivedTripList * ArchivedTripList
* Displays a paginated, searchable list of archived trips. * Displays a paginated, searchable list of archived trips.
@@ -56,19 +73,14 @@ export function ArchivedTripList({ onView }: ArchivedTripListProps) {
</span> </span>
</h2> </h2>
<div className="relative w-full sm:w-72"> <div className="w-full sm:w-72">
<svg <Input
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-text-hint)]" label="بحث"
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث برقم الرحلة أو العنوان..." placeholder="بحث برقم الرحلة أو العنوان..."
value={search} value={search}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
className="h-10 w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] pr-9 pl-3 text-[13px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15" icon={searchIcon}
dir="rtl"
/> />
</div> </div>
</div> </div>
@@ -142,22 +154,22 @@ export function ArchivedTripList({ onView }: ArchivedTripListProps) {
<strong className="text-[var(--color-text-primary)]">{pages}</strong> <strong className="text-[var(--color-text-primary)]">{pages}</strong>
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
type="button" variant="secondary"
size="sm"
disabled={page === 1} disabled={page === 1}
onClick={() => setPage(Math.max(1, page - 1))} onClick={() => setPage(Math.max(1, page - 1))}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
> >
السابق السابق
</button> </Button>
<button <Button
type="button" variant="secondary"
size="sm"
disabled={page === pages} disabled={page === pages}
onClick={() => setPage(Math.min(pages, page + 1))} onClick={() => setPage(Math.min(pages, page + 1))}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
> >
التالي التالي
</button> </Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Spinner ,Toast} from "../UI"; import { Button, Toast } from "../UI";
import { tripService } from "@/src/services/trip.service"; import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import type { TripNotification } from "@/src/hooks/useTrip"; import type { TripNotification } from "@/src/hooks/useTrip";
@@ -78,30 +78,9 @@ export function TripReportPanel({ tripId }: TripReportPanelProps) {
إنشاء تقرير الرحلة إنشاء تقرير الرحلة
</p> </p>
<button <Button type="button" onClick={handleGenerate} loading={loading}>
type="button"
onClick={handleGenerate}
disabled={loading}
style={{
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "none",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
background: loading ? "var(--color-brand-400)" : "var(--color-brand-600)",
cursor: loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
whiteSpace: "nowrap",
}}
>
{loading && <Spinner size="sm" className="text-white" />}
{loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"} {loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"}
</button> </Button>
</div> </div>
<Toast <Toast

View File

@@ -1,5 +1,6 @@
// Accessible labelled input with error and hint state. // Accessible labelled input with error and hint state.
import { forwardRef } from "react";
import { cn } from "@/src/lib/utils"; import { cn } from "@/src/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
@@ -29,7 +30,10 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
* hint="At least 8 characters" * hint="At least 8 characters"
* /> * />
*/ */
export function Input({ label, error, hint, id, icon, className, ...rest }: InputProps) { export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, error, hint, id, icon, className, ...rest },
ref,
) {
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`; const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`;
const errorId = `${inputId}-error`; const errorId = `${inputId}-error`;
const hintId = `${inputId}-hint`; const hintId = `${inputId}-hint`;
@@ -54,6 +58,7 @@ export function Input({ label, error, hint, id, icon, className, ...rest }: Inpu
)} )}
<input <input
ref={ref}
id={inputId} id={inputId}
aria-invalid={!!error} aria-invalid={!!error}
aria-describedby={ aria-describedby={
@@ -88,4 +93,4 @@ export function Input({ label, error, hint, id, icon, className, ...rest }: Inpu
)} )}
</div> </div>
); );
} });

View File

@@ -23,4 +23,5 @@ export { PageHeader } from "./PageHeader";
export { EmptyState } from "./EmptyState"; export { EmptyState } from "./EmptyState";
export { Badge } from "./Badge"; export { Badge } from "./Badge";
export { ArchiveButton } from "./ArchiveButton" export { ArchiveButton } from "./ArchiveButton"
export { FileInput } from "./FailInput"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Spinner } from "../UI"; import { Alert, Button, Modal, Spinner } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { userService } from "@/src/services/user.service"; import { userService } from "@/src/services/user.service";
import type { UserDetail } from "@/src/types/user"; import type { UserDetail } from "@/src/types/user";
@@ -12,6 +12,8 @@ interface UserDetailModalProps {
} }
// ── small helper components ─────────────────────────────────────────────────── // ── small helper components ───────────────────────────────────────────────────
// (No equivalents in the shared UI kit — DetailRow/StatusBadge/Avatar are
// purpose-built layouts, not generic form/action controls, so they stay custom.)
function DetailRow({ label, value }: { label: string; value?: string | null }) { function DetailRow({ label, value }: { label: string; value?: string | null }) {
return ( return (
<div style={{ <div style={{
@@ -29,6 +31,9 @@ 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 status
// chip's design.
function StatusBadge({ active }: { active: boolean }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ <span style={{
@@ -68,29 +73,22 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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 user details on mount // fetch user details on mount
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const data = await userService.getById(userId, token); const data = await userService.getById(userId, token);
if (!cancelled) setUser(data); if (!cancelled) setUser(data);
} catch { } catch {
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً."); if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً.");
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [userId]); }, [userId]);
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
@@ -98,144 +96,67 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
// ── render ──────────────────────────────────────────────────────────────── // ── render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={user?.name ?? "عرض المستخدم"}
style={{ subtitle="بيانات المستخدم"
position: "fixed", inset: 0, zIndex: 55, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", size="md"
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", footer={
}} <Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div {/* loading */}
onClick={e => e.stopPropagation()} {loading && (
style={{ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
width: "100%", maxWidth: 480, <Spinner size="sm" className="text-blue-600" />
background: "var(--color-surface)", <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
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: "#2563EB", fontWeight: 600, margin: 0 }}>
بيانات المستخدم
</p>
<h2 id="detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{user?.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>
</div> </div>
)}
{/* ── body ── */} {/* error */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}> {!loading && error && <Alert type="error" message={error} />}
{/* loading */} {/* content */}
{loading && ( {!loading && user && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}> <div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */} {/* avatar + name + status row */}
{!loading && error && ( <div style={{
<div style={{ display: "flex", alignItems: "center", gap: "1rem",
padding: "1rem 1.25rem", padding: "0 0 1.25rem",
borderRadius: "var(--radius-lg)", borderBottom: "1px solid var(--color-border)",
background: "#FEF2F2", border: "1px solid #FECACA", marginBottom: "0.25rem",
fontSize: 13, color: "#991B1B", fontWeight: 500, }}>
textAlign: "center", <Avatar name={user.name} />
}}> <div style={{ flex: 1, minWidth: 0 }}>
{error} <p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{user.name}</p>
</div> {user.userName && (
)} <p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{user.userName}
{/* content */} </p>
{!loading && user && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* avatar + 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",
}}>
<Avatar name={user.name} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{user.name}</p>
{user.userName && (
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{user.userName}
</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={user.isActive} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={user.phone} />
<DetailRow label="البريد الإلكتروني" value={user.email} />
<DetailRow label="الدور" value={user.role?.name} />
<DetailRow label="وصف الدور" value={user.role?.description} />
<DetailRow label="الفرع" value={user.branch?.name} />
<DetailRow label="تاريخ الإنشاء" value={fmt(user.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
{user.passwordChangedAt && (
<DetailRow label="آخر تغيير لكلمة المرور" value={fmt(user.passwordChangedAt)} />
)} )}
<div style={{ marginTop: 8 }}>
<StatusBadge active={user.isActive} />
</div>
</div> </div>
</div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={user.phone} />
<DetailRow label="البريد الإلكتروني" value={user.email} />
<DetailRow label="الدور" value={user.role?.name} />
<DetailRow label="وصف الدور" value={user.role?.description} />
<DetailRow label="الفرع" value={user.branch?.name} />
<DetailRow label="تاريخ الإنشاء" value={fmt(user.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
{user.passwordChangedAt && (
<DetailRow label="آخر تغيير لكلمة المرور" value={fmt(user.passwordChangedAt)} />
)} )}
</div> </div>
)}
{/* ── footer ── */} </Modal>
<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>
</div>
</div>
</div>
); );
} }

View File

@@ -1,48 +1,13 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form";
import * as yup from "yup"; import type { Resolver } from "react-hook-form";
import { Alert, Spinner } from "../UI"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Select } from "../UI";
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator"; import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
import type { Role } from "@/src/types/role"; import type { Role } from "@/src/types/role";
import type { FormErrors, User, UserFormData } from "@/src/types/user"; import type { User, UserFormData } from "@/src/types/user";
// ── fixed styles ─────────────────────────────────────────
const S = {
input: {
width: "100%", height: 40, padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
} as React.CSSProperties,
label: {
display: "flex", flexDirection: "column" as const,
gap: 6, fontSize: 12, fontWeight: 600,
color: "var(--color-text-secondary)",
} as React.CSSProperties,
errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties,
};
// ── yup validation ────────────────────────────────────────────────────────────
async function validate(data: UserFormData, isNew: boolean): Promise<FormErrors> {
const schema = isNew ? createUserSchema : updateUserSchema;
try {
await schema.validate(data, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<FormErrors>((acc, e) => {
const field = e.path as keyof FormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
}
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
interface UserFormModalProps { interface UserFormModalProps {
@@ -57,58 +22,45 @@ interface UserFormModalProps {
export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }: UserFormModalProps) { export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }: UserFormModalProps) {
const isNew = editUser === null; const isNew = editUser === null;
const [form, setForm] = useState<UserFormData>({ // Custom resolver wrapper — on edit, an empty password field must be
name: editUser?.name ?? "", // treated as "not provided" (skip the min(8) rule entirely) rather than
email: editUser?.email ?? "", // validated as an empty string, exactly like the old validate() call did
phone: editUser?.phone ?? "", // by stripping `password` from the object before running the schema.
password: "", const resolver: Resolver<UserFormData> = async (values, context, options) => {
roleId: editUser?.role?.id ?? "", const schema = isNew ? createUserSchema : updateUserSchema;
branchId: editUser?.branch?.id ?? "", const data = !isNew && !values.password ? { ...values, password: undefined } : values;
}); return yupResolver<UserFormData>(schema as any)(data as UserFormData, context, options);
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { firstInputRef.current?.focus(); }, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const set = (field: keyof UserFormData) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm(p => ({ ...p, [field]: e.target.value }));
// clear field error on change
if (errors[field]) setErrors(p => ({ ...p, [field]: undefined }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errs = await validate(
!isNew && !form.password
? { ...form, password: undefined as unknown as string }
: form,
isNew,
);
if (Object.keys(errs).length) { setErrors(errs); return; }
setSaving(true);
setApiError("");
const payload: Partial<UserFormData> = { ...form };
if (!isNew && !payload.password) delete payload.password;
const ok = await onSubmit(payload as UserFormData, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}; };
// dynamic styles for inputs with errors const {
const inputStyle = (field: keyof FormErrors): React.CSSProperties => ({ register,
...S.input, handleSubmit,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}), setError,
formState: { errors, isSubmitting },
} = useForm<UserFormData>({
resolver,
defaultValues: {
name: editUser?.name ?? "",
email: editUser?.email ?? "",
phone: editUser?.phone ?? "",
password: "",
roleId: editUser?.role?.id ?? "",
branchId: editUser?.branch?.id ?? "",
},
}); });
const submitHandler = async (data: UserFormData) => {
const payload: Partial<UserFormData> = { ...data };
if (!isNew && !payload.password) delete payload.password;
const ok = await onSubmit(payload as UserFormData, isNew);
if (ok) {
onClose();
} else {
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
}
};
return ( return (
<div <div
role="dialog" aria-modal="true" aria-labelledby="modal-title" role="dialog" aria-modal="true" aria-labelledby="modal-title"
@@ -145,75 +97,97 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
{isNew ? "مستخدم جديد" : editUser?.name} {isNew ? "مستخدم جديد" : editUser?.name}
</h2> </h2>
</div> </div>
<button type="button" onClick={onClose} aria-label="إغلاق" <Button
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" }}> type="button"
variant="secondary"
onClick={onClose}
aria-label="إغلاق"
style={{ width: 34, height: 34, padding: 0, fontSize: 18 }}
>
× ×
</button> </Button>
</div> </div>
{/* body */} {/* body */}
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}> <form onSubmit={handleSubmit(submitHandler)} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />} {errors.name?.type === "manual" && (
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
)}
{/* name */} {/* name */}
<label style={S.label}> <Input
الاسم الكامل * label="الاسم الكامل *"
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="أحمد الرشيدي" autoComplete="name" dir="rtl" /> {...register("name")}
{errors.name && <span style={S.errorText}>{errors.name}</span>} error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
</label> placeholder="أحمد الرشيدي"
autoComplete="name"
autoFocus
dir="rtl"
/>
{/* email + phone */} {/* email + phone */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <Input
البريد الإلكتروني label="البريد الإلكتروني"
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="ahmed@co.sa" autoComplete="email" dir="ltr" /> type="email"
{errors.email && <span style={S.errorText}>{errors.email}</span>} {...register("email")}
</label> error={errors.email?.message}
<label style={S.label}> placeholder="ahmed@co.sa"
رقم الهاتف * autoComplete="email"
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" /> dir="ltr"
{errors.phone && <span style={S.errorText}>{errors.phone}</span>} />
</label> <Input
label="رقم الهاتف *"
type="tel"
{...register("phone")}
error={errors.phone?.message}
placeholder="+966 5x xxx xxxx"
autoComplete="tel"
dir="ltr"
/>
</div> </div>
{/* password */} {/* password */}
<label style={S.label}> <Input
{isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"} label={isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"}
<input style={inputStyle("password")} type="password" value={form.password} onChange={set("password")} placeholder="••••••••" autoComplete={isNew ? "new-password" : "off"} dir="ltr" /> type="password"
{errors.password && <span style={S.errorText}>{errors.password}</span>} {...register("password")}
</label> error={errors.password?.message}
placeholder="••••••••"
autoComplete={isNew ? "new-password" : "off"}
dir="ltr"
/>
{/* role + branch */} {/* role + branch */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <Select
الدور * label="الدور *"
<select style={{ ...inputStyle("roleId"), cursor: "pointer" }} value={form.roleId} onChange={set("roleId")} dir="rtl"> {...register("roleId")}
<option value="">اختر الدور</option> error={errors.roleId?.message}
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)} dir="rtl"
</select> >
{errors.roleId && <span style={S.errorText}>{errors.roleId}</span>} <option value="">اختر الدور</option>
</label> {roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
<label style={S.label}> </Select>
الفرع * <Select
<select style={{ ...inputStyle("branchId"), cursor: "pointer" }} value={form.branchId} onChange={set("branchId")} dir="rtl"> label="الفرع *"
<option value="">اختر الفرع</option> {...register("branchId")}
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)} error={errors.branchId?.message}
</select> dir="rtl"
{errors.branchId && <span style={S.errorText}>{errors.branchId}</span>} >
</label> <option value="">اختر الفرع</option>
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</Select>
</div> </div>
{/* actions */} {/* actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button type="button" onClick={onClose} disabled={saving} <Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء إلغاء
</button> </Button>
<button type="submit" disabled={saving} <Button type="submit" variant="primary" loading={isSubmitting}>
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}> {isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة المستخدم" : "حفظ التغييرات"}
{saving && <Spinner size="sm" className="text-white" />} </Button>
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة المستخدم" : "حفظ التغييرات"}
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,19 +1,11 @@
"use client"; "use client";
import { Spinner } from "../UI"; import { Badge, Button, EmptyState, Spinner } from "../UI";
import type { User } from "@/src/types/user"; import type { User } from "@/src/types/user";
// ── role badge ───────────────────────────────────────────────────────────────
function RoleBadge({ name }: { name?: string }) {
const isAdmin = name === "مدير النظام";
return (
<span style={{ display: "inline-flex", alignItems: "center", borderRadius: "var(--radius-full)", border: isAdmin ? "1px solid #BFDBFE" : "1px solid var(--color-border)", background: isAdmin ? "#EFF6FF" : "var(--color-surface-muted)", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: isAdmin ? "#1D4ED8" : "var(--color-text-muted)" }}>
{name ?? "—"}
</span>
);
}
// ── status badge ───────────────────────────────────────────────────────────── // ── 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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( 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" }}> <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" }}>
@@ -24,6 +16,11 @@ function StatusBadge({ active }: { active: boolean }) {
} }
// ── icon button ────────────────────────────────────────────────────────────── // ── 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
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
// square icon chip. Forcing it here would collapse the view/edit/delete
// green/blue/red color-coding into a single variant and misshape the button.
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return ( return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }} <button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
@@ -82,17 +79,17 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
</div> </div>
) : users.length === 0 ? ( ) : users.length === 0 ? (
/* empty state */ /* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> <EmptyState
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> icon="👥"
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."} title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
</p> action={
{!search && ( !search && (
<button type="button" onClick={onAddFirst} <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}> أضف أول مستخدم
أضف أول مستخدم </Button>
</button> )
)} }
</div> />
) : ( ) : (
/* data rows */ /* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> <ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
@@ -110,7 +107,7 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
</div> </div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span> <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
<span style={{ color: "var(--color-text-secondary)" }}>{u.branch?.name ?? "—"}</span> <span style={{ color: "var(--color-text-secondary)" }}>{u.branch?.name ?? "—"}</span>
<RoleBadge name={u.role?.name} /> <Badge label={u.role?.name ?? "—"} color={u.role?.name === "مدير النظام" ? "indigo" : "slate"} />
<StatusBadge active={u.isActive} /> <StatusBadge active={u.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> <span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} {new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
@@ -150,15 +147,24 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
{[ <Button
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, type="button"
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, variant="secondary"
].map(btn => ( size="sm"
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled} onClick={() => onPageChange(Math.max(1, page - 1))}
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)" }}> disabled={page === 1}
{btn.label} >
</button> السابق
))} </Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Spinner } from "../../UI"; import { Alert, Button, Modal, Spinner } from "../../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { archivedUserService } from "@/src/services/archive/archivedUser.service"; import { archivedUserService } from "@/src/services/archive/archivedUser.service";
import type { ArchivedUser } from "@/src/types/user"; import type { ArchivedUser } from "@/src/types/user";
@@ -12,6 +12,8 @@ interface ArchivedUserDetailModalProps {
} }
// ── small helper components ─────────────────────────────────────────────────── // ── small helper components ───────────────────────────────────────────────────
// (No equivalents in the shared UI kit — DetailRow/StatusBadge/Avatar are
// purpose-built layouts, not generic form/action controls, so they stay custom.)
function DetailRow({ label, value }: { label: string; value?: string | null }) { function DetailRow({ label, value }: { label: string; value?: string | null }) {
return ( return (
<div style={{ <div style={{
@@ -29,6 +31,9 @@ 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 status
// chip's design.
function StatusBadge({ active }: { active: boolean }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ <span style={{
@@ -68,29 +73,22 @@ export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailM
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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 user details on mount // fetch archived user details on mount
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const data = await archivedUserService.getById(userId, token); const data = await archivedUserService.getById(userId, token);
if (!cancelled) setUser(data); if (!cancelled) setUser(data);
} catch { } catch {
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم المؤرشف. يرجى المحاولة لاحقاً."); if (!cancelled) setError("تعذّر تحميل بيانات المستخدم المؤرشف. يرجى المحاولة لاحقاً.");
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [userId]); }, [userId]);
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
@@ -98,138 +96,61 @@ useEffect(() => {
// ── render ──────────────────────────────────────────────────────────────── // ── render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archived-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={user?.name ?? "عرض المستخدم"}
style={{ subtitle="مستخدم مؤرشف"
position: "fixed", inset: 0, zIndex: 55, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", size="md"
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", footer={
}} <Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div {/* loading */}
onClick={e => e.stopPropagation()} {loading && (
style={{ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
width: "100%", maxWidth: 480, <Spinner size="sm" className="text-blue-600" />
background: "var(--color-surface)", <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
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-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{user?.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>
</div> </div>
)}
{/* ── body ── */} {/* error */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}> {!loading && error && <Alert type="error" message={error} />}
{/* loading */} {/* content */}
{loading && ( {!loading && user && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}> <div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */} {/* avatar + name + status row */}
{!loading && error && ( <div style={{
<div style={{ display: "flex", alignItems: "center", gap: "1rem",
padding: "1rem 1.25rem", padding: "0 0 1.25rem",
borderRadius: "var(--radius-lg)", borderBottom: "1px solid var(--color-border)",
background: "#FEF2F2", border: "1px solid #FECACA", marginBottom: "0.25rem",
fontSize: 13, color: "#991B1B", fontWeight: 500, }}>
textAlign: "center", <Avatar name={user.name} />
}}> <div style={{ flex: 1, minWidth: 0 }}>
{error} <p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{user.name}</p>
</div> {user.userName && (
)} <p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{user.userName}
{/* content */} </p>
{!loading && user && ( )}
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl"> <div style={{ marginTop: 8 }}>
<StatusBadge active={user.isActive} />
{/* avatar + 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",
}}>
<Avatar name={user.name} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{user.name}</p>
{user.userName && (
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{user.userName}
</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={user.isActive} />
</div>
</div>
</div> </div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={user.phone} />
<DetailRow label="البريد الإلكتروني" value={user.email} />
<DetailRow label="تاريخ الإنشاء" value={fmt(user.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
</div> </div>
)} </div>
</div>
{/* ── footer ── */} {/* detail rows */}
<div style={{ <DetailRow label="رقم الهاتف" value={user.phone} />
padding: "1rem 1.5rem", <DetailRow label="البريد الإلكتروني" value={user.email} />
borderTop: "1px solid var(--color-border)", <DetailRow label="تاريخ الإنشاء" value={fmt(user.createdAt)} />
background: "var(--color-surface-muted)", <DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
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>
</div> </div>
</div> )}
</div> </Modal>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedUserTable } from "./Archivedusertable"; import { ArchivedUserTable } from "./Archivedusertable";
import { ArchivedUserDetailModal } from "./Archiveduserdetailmodal"; import { ArchivedUserDetailModal } from "./Archiveduserdetailmodal";
import { useArchivedUsers } from "@/src/hooks/archive/useArchivedUsers"; import { useArchivedUsers } from "@/src/hooks/archive/useArchivedUsers";
@@ -10,6 +10,21 @@ interface ArchivedUsersModalProps {
onClose: () => void; onClose: () => void;
} }
const searchIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);
export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) { export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
const [viewUserId, setViewUserId] = useState<string | null>(null); const [viewUserId, setViewUserId] = useState<string | null>(null);
@@ -20,79 +35,27 @@ export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
} = useArchivedUsers(); } = useArchivedUsers();
return ( return (
<div <>
role="dialog" aria-modal="true" aria-labelledby="archive-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 70,
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
>
{viewUserId && ( {viewUserId && (
<ArchivedUserDetailModal userId={viewUserId} onClose={() => setViewUserId(null)} /> <ArchivedUserDetailModal userId={viewUserId} onClose={() => setViewUserId(null)} />
)} )}
<div <Modal
onClick={e => e.stopPropagation()} open
style={{ onClose={onClose}
width: "100%", maxWidth: 920, size="lg"
background: "var(--color-surface-sunken)", subtitle="الأرشيف"
borderRadius: "var(--radius-xl)", title={`المستخدمون المؤرشفون (${total})`}
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
> >
{/* header */} <div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{ <div style={{ maxWidth: 320 }}>
display: "flex", alignItems: "center", justifyContent: "space-between", <Input
padding: "1.25rem 1.5rem", label="بحث"
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
المستخدمون المؤرشفون
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف..." placeholder="بحث بالاسم أو الهاتف..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
icon={searchIcon}
dir="rtl" dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>
@@ -108,7 +71,7 @@ export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
onPageChange={setPage} onPageChange={setPage}
/> />
</div> </div>
</div> </Modal>
</div> </>
); );
} }

View File

@@ -1,9 +1,11 @@
"use client"; "use client";
import { Spinner } from "../../UI"; import { Button, EmptyState, Spinner } from "../../UI";
import type { ArchivedUser } from "@/src/types/user"; import type { ArchivedUser } from "@/src/types/user";
// ── status badge ───────────────────────────────────────────────────────────── // ── 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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( 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" }}> <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,11 @@ function StatusBadge({ active }: { active: boolean }) {
} }
// ── icon button ────────────────────────────────────────────────────────────── // ── 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
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
// square icon chip. Forcing it here would misshape the button and drop the
// semantic green "view" color.
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return ( return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }} <button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
@@ -68,11 +75,10 @@ export function ArchivedUserTable({ users, loading, search, page, pages, onView,
</div> </div>
) : users.length === 0 ? ( ) : users.length === 0 ? (
/* empty state */ /* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> <EmptyState
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> icon="🗄️"
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."} title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
</p> />
</div>
) : ( ) : (
/* data rows */ /* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> <ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
@@ -117,15 +123,24 @@ export function ArchivedUserTable({ users, loading, search, page, pages, onView,
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
{[ <Button
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, type="button"
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, variant="secondary"
].map(btn => ( size="sm"
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled} onClick={() => onPageChange(Math.max(1, page - 1))}
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)" }}> disabled={page === 1}
{btn.label} >
</button> السابق
))} </Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { import {
createCarSchema, createCarSchema,
@@ -18,35 +18,9 @@ import type {
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
import { branchService } from "@/src/services/branch.service"; import { branchService } from "@/src/services/branch.service";
// ── Shared input style ──────────────────────────────────────────────────────── // ── Shared styles ─────────────────────────────────────────────────────────────
// Only section-heading styling is left here — every input/select/label/error
const inputBase: React.CSSProperties = { // visual is now owned by the shared <Input /> / <Select /> components.
width: "100%",
height: 40,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
};
const labelStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
const sectionHeadingStyle: React.CSSProperties = { const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11, fontSize: 11,
@@ -57,6 +31,8 @@ const sectionHeadingStyle: React.CSSProperties = {
margin: "0.5rem 0 0", margin: "0.5rem 0 0",
}; };
const FORM_ID = "car-form";
// ── Date helper ─────────────────────────────────────────────────────────────── // ── Date helper ───────────────────────────────────────────────────────────────
// <input type="date"> gives "YYYY-MM-DD"; backend expects full ISO-8601. // <input type="date"> gives "YYYY-MM-DD"; backend expects full ISO-8601.
@@ -140,9 +116,13 @@ export function CarFormModal({
register, register,
handleSubmit, handleSubmit,
setError, setError,
setFocus,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<CarFormValues>({ } = useForm<CarFormValues>({
resolver: yupResolver(isNew ? createCarSchema : updateCarSchema) as never, // The create/update schemas have structurally different required fields,
// so yup's inferred types don't unify — cast the schema itself (not the
// yupResolver() call) to sidestep the mismatch.
resolver: yupResolver<CarFormValues>((isNew ? createCarSchema : updateCarSchema) as any),
defaultValues: { defaultValues: {
manufacturer: editCar?.manufacturer ?? "", manufacturer: editCar?.manufacturer ?? "",
model: editCar?.model ?? "", model: editCar?.model ?? "",
@@ -169,18 +149,14 @@ export function CarFormModal({
}); });
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
const firstRef = useRef<HTMLInputElement>(null);
// register("manufacturer") already provides a ref for this input, so we
// focus it via RHF's setFocus instead of a separate useRef — assigning
// both `ref={firstRef}` and `{...register(...)}` on the same element
// causes the second ref to silently overwrite the first.
useEffect(() => { useEffect(() => {
firstRef.current?.focus(); setFocus("manufacturer");
}, []); }, [setFocus]);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
// Numeric fields: empty string -> undefined (never NaN), matching the // Numeric fields: empty string -> undefined (never NaN), matching the
// behavior of the old parseNum() helper. // behavior of the old parseNum() helper.
@@ -191,11 +167,6 @@ export function CarFormModal({
setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)), setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)),
}); });
const withError = (hasError: boolean): React.CSSProperties => ({
...inputBase,
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
// ── Submit ──────────────────────────────────────────────────────────────── // ── Submit ────────────────────────────────────────────────────────────────
const submitHandler = async (data: CarFormValues) => { const submitHandler = async (data: CarFormValues) => {
@@ -244,351 +215,173 @@ export function CarFormModal({
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" open
aria-modal="true" title={isNew ? "مركبة جديدة" : `${editCar?.manufacturer} ${editCar?.model}`}
aria-labelledby="car-modal-title" subtitle={isNew ? "إضافة مركبة" : "تعديل مركبة"}
onClick={(e) => { onClose={onClose}
if (e.target === e.currentTarget) onClose(); size="lg"
}} footer={
style={{ <>
position: "fixed", <Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
inset: 0, إلغاء
zIndex: 50, </Button>
background: "rgba(15,23,42,0.55)", <Button type="submit" form={FORM_ID} loading={isSubmitting}>
backdropFilter: "blur(4px)", {isNew ? "إضافة المركبة" : "حفظ التغييرات"}
display: "flex", </Button>
alignItems: "center", </>
justifyContent: "center", }
padding: "1rem",
overflowY: "auto",
}}
> >
<div <form
onClick={(e) => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", noValidate
maxWidth: 620, style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
background: "var(--color-surface)", dir="rtl"
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
margin: "auto",
}}
> >
{/* Header */} {errors.manufacturer?.type === "manual" && (
<div <Alert
style={{ type="error"
display: "flex", message={errors.manufacturer.message ?? ""}
alignItems: "center", onClose={() => setApiError("")}
justifyContent: "space-between", />
padding: "1.25rem 1.5rem", )}
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", {/* ── Section: Basic Info ── */}
}} <p style={sectionHeadingStyle}>بيانات أساسية</p>
>
<div> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<p <Input
style={{ label="الشركة المصنعة *"
fontSize: 11, {...register("manufacturer")}
letterSpacing: "0.3em", error={
textTransform: "uppercase", errors.manufacturer && errors.manufacturer.type !== "manual"
color: "#2563EB", ? errors.manufacturer.message
fontWeight: 600, : undefined
margin: 0, }
}} placeholder="تويوتا"
> dir="rtl"
{isNew ? "إضافة مركبة" : "تعديل مركبة"} autoComplete="off"
</p> />
<h2 <Input
id="car-modal-title" label="الموديل *"
style={{ {...register("model")}
fontSize: 17, error={errors.model?.message}
fontWeight: 700, placeholder="لاند كروزر"
color: "var(--color-text-primary)", dir="rtl"
margin: "4px 0 0", />
}}
>
{isNew ? "مركبة جديدة" : `${editCar?.manufacturer} ${editCar?.model}`}
</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>
</div> </div>
{/* Form */} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<form <Input
onSubmit={handleSubmit(submitHandler)} label="سنة الصنع *"
noValidate type="number"
style={{ min={1900}
padding: "1.5rem", max={new Date().getFullYear() + 1}
display: "flex", {...numberField("year")}
flexDirection: "column", error={errors.year?.message}
gap: "1rem", dir="ltr"
maxHeight: "75vh", />
overflowY: "auto", <Input label="اللون" {...register("color")} placeholder="أبيض" dir="rtl" />
}} <Input label="نوع اللوحة" {...register("plateType")} placeholder="خاص" dir="rtl" />
dir="rtl" </div>
>
{errors.manufacturer?.type === "manual" && (
<Alert
type="error"
message={errors.manufacturer.message ?? ""}
onClose={() => setApiError("")}
/>
)}
{/* ── Section: Basic Info ── */} {/* ── Section: Plate ── */}
<p style={sectionHeadingStyle}>بيانات أساسية</p> <p style={sectionHeadingStyle}>بيانات اللوحة</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="رقم اللوحة *"
{...register("plateNumber")}
error={errors.plateNumber?.message}
placeholder="1234"
dir="ltr"
/>
<Input
label="حروف اللوحة *"
{...register("plateLetters")}
error={errors.plateLetters?.message}
placeholder="أ ب ج"
dir="rtl"
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> {/* ── Section: Registration & Legal ── */}
<label style={labelStyle}> <p style={sectionHeadingStyle}>الترخيص والتأمين</p>
الشركة المصنعة * <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<input <Input
ref={firstRef} label="رقم الاستمارة *"
style={withError(!!errors.manufacturer && errors.manufacturer.type !== "manual")} {...register("registrationNumber")}
{...register("manufacturer")} error={errors.registrationNumber?.message}
placeholder="تويوتا" placeholder="SA-001234"
dir="rtl" dir="ltr"
autoComplete="off" />
/> <Input
{errors.manufacturer && errors.manufacturer.type !== "manual" && ( label="رقم الهيكل (VIN)"
<span style={errorTextStyle}>{errors.manufacturer.message}</span> {...register("vinNumber")}
)} error={errors.vinNumber?.message}
</label> placeholder="1HGBH41JXMN109186"
<label style={labelStyle}> dir="ltr"
الموديل * />
<input <Input
style={withError(!!errors.model)} label="انتهاء الاستمارة"
{...register("model")} type="date"
placeholder="لاند كروزر" {...register("registrationExpiryDate")}
dir="rtl" />
/> <Select label="حالة التأمين" {...register("insuranceStatus")} dir="rtl">
{errors.model && <span style={errorTextStyle}>{errors.model.message}</span>} <option value="Valid">سارٍ</option>
</label> <option value="Expired">منتهي</option>
</div> <option value="NotInsured">غير مؤمَّن</option>
</Select>
<Input
label="انتهاء التأمين"
type="date"
{...register("insuranceExpiryDate")}
/>
<Input
label="انتهاء الفحص الدوري"
type="date"
{...register("inspectionExpiryDate")}
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}> {/* ── Section: Operational ── */}
<label style={labelStyle}> <p style={sectionHeadingStyle}>بيانات تشغيلية</p>
سنة الصنع * <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<input <Select label="الفرع" {...register("branchId")} dir="rtl">
style={withError(!!errors.year)} <option value="">اختر الفرع</option>
type="number" {branches.map((b) => (
min={1900} <option key={b.id} value={b.id}>
max={new Date().getFullYear() + 1} {b.name}
{...numberField("year")} </option>
dir="ltr" ))}
/> </Select>
{errors.year && <span style={errorTextStyle}>{errors.year.message}</span>} <Select label="الحالة" {...register("currentStatus")} dir="rtl">
</label> <option value="Active">نشط</option>
<label style={labelStyle}> <option value="InMaintenance">صيانة</option>
اللون <option value="InTrip">في رحلة</option>
<input style={inputBase} {...register("color")} placeholder="أبيض" dir="rtl" /> <option value="Inactive">غير نشط</option>
</label> </Select>
<label style={labelStyle}> <Input label="رقم GPS" {...register("gpsDeviceId")} placeholder="GPS-001" dir="ltr" />
نوع اللوحة <Input
<input style={inputBase} {...register("plateType")} placeholder="خاص" dir="rtl" /> label="الطاقة الاستيعابية"
</label> type="number"
</div> min={0}
{...numberField("capacity")}
{/* ── Section: Plate ── */} error={errors.capacity?.message}
<p style={sectionHeadingStyle}>بيانات اللوحة</p> placeholder="0"
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> dir="ltr"
<label style={labelStyle}> />
رقم اللوحة * <Input
<input label="الوزن (كجم)"
style={withError(!!errors.plateNumber)} type="number"
{...register("plateNumber")} min={0}
placeholder="1234" {...numberField("weight")}
dir="ltr" error={errors.weight?.message}
/> placeholder="0"
{errors.plateNumber && ( dir="ltr"
<span style={errorTextStyle}>{errors.plateNumber.message}</span> />
)} </div>
</label> </form>
<label style={labelStyle}> </Modal>
حروف اللوحة *
<input
style={withError(!!errors.plateLetters)}
{...register("plateLetters")}
placeholder="أ ب ج"
dir="rtl"
/>
{errors.plateLetters && (
<span style={errorTextStyle}>{errors.plateLetters.message}</span>
)}
</label>
</div>
{/* ── Section: Registration & Legal ── */}
<p style={sectionHeadingStyle}>الترخيص والتأمين</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
رقم الاستمارة *
<input
style={withError(!!errors.registrationNumber)}
{...register("registrationNumber")}
placeholder="SA-001234"
dir="ltr"
/>
{errors.registrationNumber && (
<span style={errorTextStyle}>{errors.registrationNumber.message}</span>
)}
</label>
<label style={labelStyle}>
رقم الهيكل (VIN)
<input
style={withError(!!errors.vinNumber)}
{...register("vinNumber")}
placeholder="1HGBH41JXMN109186"
dir="ltr"
/>
{errors.vinNumber && (
<span style={errorTextStyle}>{errors.vinNumber.message}</span>
)}
</label>
<label style={labelStyle}>
انتهاء الاستمارة
<input style={inputBase} type="date" {...register("registrationExpiryDate")} />
</label>
<label style={labelStyle}>
حالة التأمين
<select style={{ ...inputBase, cursor: "pointer" }} {...register("insuranceStatus")} dir="rtl">
<option value="Valid">سارٍ</option>
<option value="Expired">منتهي</option>
<option value="NotInsured">غير مؤمَّن</option>
</select>
</label>
<label style={labelStyle}>
انتهاء التأمين
<input style={inputBase} type="date" {...register("insuranceExpiryDate")} />
</label>
<label style={labelStyle}>
انتهاء الفحص الدوري
<input style={inputBase} type="date" {...register("inspectionExpiryDate")} />
</label>
</div>
{/* ── Section: Operational ── */}
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
الفرع
<select style={{ ...inputBase, cursor: "pointer" }} {...register("branchId")} dir="rtl">
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</label>
<label style={labelStyle}>
الحالة
<select style={{ ...inputBase, cursor: "pointer" }} {...register("currentStatus")} dir="rtl">
<option value="Active">نشط</option>
<option value="InMaintenance">صيانة</option>
<option value="InTrip">في رحلة</option>
<option value="Inactive">غير نشط</option>
</select>
</label>
<label style={labelStyle}>
رقم GPS
<input style={inputBase} {...register("gpsDeviceId")} placeholder="GPS-001" dir="ltr" />
</label>
<label style={labelStyle}>
الطاقة الاستيعابية
<input
style={withError(!!errors.capacity)}
type="number"
min={0}
{...numberField("capacity")}
placeholder="0"
dir="ltr"
/>
{errors.capacity && (
<span style={errorTextStyle}>{errors.capacity.message}</span>
)}
</label>
<label style={labelStyle}>
الوزن (كجم)
<input
style={withError(!!errors.weight)}
type="number"
min={0}
{...numberField("weight")}
placeholder="0"
dir="ltr"
/>
{errors.weight && <span style={errorTextStyle}>{errors.weight.message}</span>}
</label>
</div>
{/* ── Actions ── */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: isSubmitting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: isSubmitting ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{isSubmitting && <Spinner size="sm" className="text-white" />}
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة المركبة" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { Spinner } from "../../UI"; import { Spinner, Button } from "../../UI";
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car"; import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
import type { Car, InsuranceStatus } from "@/src/types/car"; import type { Car, InsuranceStatus } from "@/src/types/car";
@@ -78,10 +78,15 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
{car ? `${car.manufacturer} ${car.model}` : "عرض المركبة"} {car ? `${car.manufacturer} ${car.model}` : "عرض المركبة"}
</h2> </h2>
</div> </div>
<button type="button" onClick={onClose} aria-label="إغلاق" <Button
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" }}> type="button"
variant="secondary"
onClick={onClose}
aria-label="إغلاق"
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
>
× ×
</button> </Button>
</div> </div>
{/* body */} {/* body */}
@@ -138,10 +143,9 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
{/* footer */} {/* footer */}
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end" }}> <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} <Button type="button" variant="secondary" 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> </Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Button } from "../../UI";
import { ArchivedCarTable } from "./Archivedcartable"; import { ArchivedCarTable } from "./Archivedcartable";
import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel"; import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel";
import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars"; import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars";
@@ -69,36 +69,33 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
</span> </span>
</h2> </h2>
</div> </div>
<button type="button" onClick={onClose} aria-label="إغلاق" <Button
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" }}> type="button"
variant="secondary"
onClick={onClose}
aria-label="إغلاق"
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
>
× ×
</button> </Button>
</div> </div>
{/* body */} {/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}> <div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */} {/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text" type="text"
placeholder="بحث بالماركة أو اللوحة..." placeholder="بحث بالماركة أو اللوحة..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
style={{ icon={
width: "100%", height: 40, <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
paddingRight: 36, paddingLeft: 12, <circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
borderRadius: "var(--radius-lg)", </svg>
border: "1px solid var(--color-border)", }
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Spinner } from "../../UI"; import { Spinner, Button } from "../../UI";
import { STATUS_MAP, fmtDateShort } from "@/src/types/car"; import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
import type { Car } from "@/src/types/car"; import type { Car } from "@/src/types/car";
@@ -19,10 +19,22 @@ const thStyle: React.CSSProperties = {
// ── icon button ────────────────────────────────────────────────────────────── // ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return ( return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }} <Button
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}> type="button"
variant="ghost"
title={title}
aria-label={title}
onClick={e => { e.stopPropagation(); onClick(); }}
style={{
width: 32, height: 32, padding: 0,
borderRadius: "var(--radius-md)",
border: `1px solid ${borderColor}`,
background: bg,
color,
}}
>
{children} {children}
</button> </Button>
); );
} }
@@ -120,10 +132,16 @@ export function ArchivedCarTable({ cars, loading, search, page, pages, onView, o
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => ( ].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled} <Button
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)" }}> key={btn.label}
type="button"
variant="secondary"
size="sm"
onClick={btn.action}
disabled={btn.disabled}
>
{btn.label} {btn.label}
</button> </Button>
))} ))}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Spinner } from "../UI"; import { Alert, Button, Modal, Select, Spinner } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { roleService } from "@/src/services/role.service"; import { roleService } from "@/src/services/role.service";
import { Permission, Role } from "@/src/types/role"; import { Permission, Role } from "@/src/types/role";
@@ -14,6 +14,7 @@ interface RoleDetailModalProps {
onRemove: (roleId: string, permissionId: string) => Promise<boolean>; onRemove: (roleId: string, permissionId: string) => Promise<boolean>;
} }
// (No shared-UI equivalents — purpose-built layouts, not generic controls.)
function DetailRow({ label, value }: { label: string; value?: string | null }) { function DetailRow({ label, value }: { label: string; value?: string | null }) {
return ( return (
<div style={{ <div style={{
@@ -30,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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ <span style={{
@@ -54,12 +57,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
const [pendingPermId, setPendingPermId] = useState(""); const [pendingPermId, setPendingPermId] = useState("");
const [mutating, setMutating] = useState(false); const [mutating, setMutating] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const loadRole = async () => { const loadRole = async () => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
@@ -115,165 +112,114 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
); );
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="role-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={role?.name ?? "عرض الدور"}
style={{ subtitle="تفاصيل الدور"
position: "fixed", inset: 0, zIndex: 55, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", size="md"
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", footer={
}} <Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div onClick={e => e.stopPropagation()} style={{ {loading && (
width: "100%", maxWidth: 520, <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
background: "var(--color-surface)", <Spinner size="sm" className="text-blue-600" />
borderRadius: "var(--radius-2xl)", <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
border: "1px solid var(--color-border)", </div>
boxShadow: "0 24px 64px rgba(0,0,0,.18)", )}
overflow: "hidden", display: "flex", flexDirection: "column",
}}> {!loading && error && <Alert type="error" message={error} />}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between", {!loading && role && (
padding: "1.25rem 1.5rem", <div dir="rtl">
borderBottom: "1px solid var(--color-border)", <div style={{ display: "flex", alignItems: "center", gap: "1rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--color-border)", marginBottom: "0.25rem" }}>
background: "var(--color-surface-muted)", <div style={{
}}> width: 56, height: 56, borderRadius: "var(--radius-xl)",
<div> background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> display: "flex", alignItems: "center", justifyContent: "center",
تفاصيل الدور fontSize: 22, flexShrink: 0,
</p> }}>
<h2 id="role-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> 🛡
{role?.name ?? "عرض الدور"} </div>
</h2> <div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
{role.description && (
<p style={{ marginTop: 3, fontSize: 12, color: "var(--color-text-muted)" }}>{role.description}</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={role.isActive} />
</div>
</div>
</div> </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>
</div>
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}> <DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
{loading && ( {role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
<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>
)}
{!loading && error && ( <div style={{ paddingTop: "0.75rem" }}>
<div style={{ padding: "1rem 1.25rem", borderRadius: "var(--radius-lg)", background: "#FEF2F2", border: "1px solid #FECACA", fontSize: 13, color: "#991B1B", fontWeight: 500, textAlign: "center" }}> <p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 10px" }}>
{error} الصلاحيات ({role.permissions?.length ?? 0})
</div> </p>
)}
{!loading && role && ( {assignablePermissions.length > 0 && (
<div dir="rtl"> <div style={{ display: "flex", gap: 8, marginBottom: 12, alignItems: "flex-start" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--color-border)", marginBottom: "0.25rem" }}> <div style={{ flex: 1 }}>
<div style={{ <Select
width: 56, height: 56, borderRadius: "var(--radius-xl)", value={pendingPermId}
background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)", onChange={(e) => setPendingPermId(e.target.value)}
display: "flex", alignItems: "center", justifyContent: "center", disabled={mutating}
fontSize: 22, flexShrink: 0, dir="rtl"
}}> >
🛡 <option value="">اختر صلاحية لإضافتها</option>
</div> {assignablePermissions.map((p) => (
<div style={{ flex: 1, minWidth: 0 }}> <option key={p.id} value={p.id}>{p.name}</option>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
{role.description && (
<p style={{ marginTop: 3, fontSize: 12, color: "var(--color-text-muted)" }}>{role.description}</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={role.isActive} />
</div>
</div>
</div>
<DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
{role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
<div style={{ paddingTop: "0.75rem" }}>
<p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 10px" }}>
الصلاحيات ({role.permissions?.length ?? 0})
</p>
{assignablePermissions.length > 0 && (
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<select
value={pendingPermId}
onChange={(e) => setPendingPermId(e.target.value)}
disabled={mutating}
style={{
flex: 1, height: 36, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)", fontSize: 12,
padding: "0 0.5rem", fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)", background: "var(--color-surface)",
}}
>
<option value="">اختر صلاحية لإضافتها</option>
{assignablePermissions.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button
type="button" onClick={handleAssign} disabled={!pendingPermId || mutating}
style={{
height: 36, padding: "0 0.875rem", borderRadius: "var(--radius-md)",
border: "none", background: "var(--color-brand-600)", color: "#FFF",
fontSize: 12, fontWeight: 700,
cursor: pendingPermId && !mutating ? "pointer" : "not-allowed",
opacity: !pendingPermId || mutating ? 0.6 : 1,
fontFamily: "var(--font-sans)",
display: "flex", alignItems: "center", gap: 6,
}}
>
{mutating && <Spinner size="sm" className="text-white" />}
إضافة
</button>
</div>
)}
{role.permissions && role.permissions.length > 0 ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
{role.permissions.map(({ permission }) => (
<span key={permission.id} style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "0.25rem 0.4rem 0.25rem 0.6rem", borderRadius: "var(--radius-md)",
background: "#EFF6FF", border: "1px solid #BFDBFE",
fontSize: 11, fontWeight: 600, color: "#1D4ED8",
}}>
{permission.name}
<button
type="button" onClick={() => handleRemove(permission.id)} disabled={mutating}
aria-label={`إزالة ${permission.name}`}
style={{
background: "none", border: "none",
cursor: mutating ? "not-allowed" : "pointer",
color: "#1D4ED8", fontSize: 13, lineHeight: 1, padding: 0,
}}
>
×
</button>
</span>
))} ))}
</div> </Select>
) : ( </div>
<p style={{ fontSize: 13, color: "var(--color-text-hint)", fontStyle: "italic" }}>لا توجد صلاحيات مسندة لهذا الدور</p> <Button
)} type="button"
size="sm"
onClick={handleAssign}
disabled={!pendingPermId}
loading={mutating}
>
إضافة
</Button>
</div> </div>
</div> )}
)}
</div>
<div style={{ {role.permissions && role.permissions.length > 0 ? (
padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end", {role.permissions.map(({ permission }) => (
}}> <span key={permission.id} style={{
<button type="button" onClick={onClose} display: "inline-flex", alignItems: "center", gap: 6,
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)" }}> padding: "0.25rem 0.4rem 0.25rem 0.6rem", borderRadius: "var(--radius-md)",
إغلاق background: "#EFF6FF", border: "1px solid #BFDBFE",
</button> fontSize: 11, fontWeight: 600, color: "#1D4ED8",
}}>
{permission.name}
<button
type="button" onClick={() => handleRemove(permission.id)} disabled={mutating}
aria-label={`إزالة ${permission.name}`}
style={{
background: "none", border: "none",
cursor: mutating ? "not-allowed" : "pointer",
color: "#1D4ED8", fontSize: 13, lineHeight: 1, padding: 0,
}}
>
×
</button>
</span>
))}
</div>
) : (
<p style={{ fontSize: 13, color: "var(--color-text-hint)", fontStyle: "italic" }}>لا توجد صلاحيات مسندة لهذا الدور</p>
)}
</div>
</div> </div>
</div> )}
</div> </Modal>
); );
} }

View File

@@ -1,46 +1,12 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useForm, Controller } from "react-hook-form";
import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Spinner } from "../UI"; import { Alert, Button, Input, Modal, Textarea } from "../UI";
import { createRoleSchema, updateRoleSchema, type RoleFormErrors } from "@/src/validations/role.validator"; import { createRoleSchema, updateRoleSchema } from "@/src/validations/role.validator";
import { Permission, Role, RoleFormData } from "@/src/types/role"; import { Permission, Role, RoleFormData } from "@/src/types/role";
// ── Styles ──────────────────────────────────────────────────────────────────── const FORM_ID = "role-form";
const S = {
input: {
width: "100%", height: 40, padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
} as React.CSSProperties,
label: {
display: "flex", flexDirection: "column" as const,
gap: 6, fontSize: 12, fontWeight: 600,
color: "var(--color-text-secondary)",
} as React.CSSProperties,
errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties,
};
// ── Validation ────────────────────────────────────────────────────────────────
async function validate(data: RoleFormData, isNew: boolean): Promise<RoleFormErrors> {
const schema = isNew ? createRoleSchema : updateRoleSchema;
try {
await schema.validate(data, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<RoleFormErrors>((acc, e) => {
const field = e.path as keyof RoleFormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
}
// ── Permission checkbox group ───────────────────────────────────────────────── // ── Permission checkbox group ─────────────────────────────────────────────────
function PermissionGroup({ function PermissionGroup({
@@ -100,180 +66,142 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
const currentPermIds = editRole?.permissions?.map(p => p.permission.id) ?? []; const currentPermIds = editRole?.permissions?.map(p => p.permission.id) ?? [];
const [form, setForm] = useState<RoleFormData>({ const {
name: editRole?.name ?? "", register,
description: editRole?.description ?? "", handleSubmit,
permissionIds: currentPermIds, control,
setError,
setValue,
formState: { errors, isSubmitting },
} = useForm<RoleFormData>({
// Cast the schema itself and pin the generic explicitly — create/update
// schemas differ structurally in which fields are required, so yup can't
// unify them into RoleFormData on its own.
resolver: yupResolver<RoleFormData>((isNew ? createRoleSchema : updateRoleSchema) as any),
defaultValues: {
name: editRole?.name ?? "",
description: editRole?.description ?? "",
permissionIds: currentPermIds,
},
}); });
const [errors, setErrors] = useState<RoleFormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { firstInputRef.current?.focus(); }, []); const submitHandler = async (data: RoleFormData) => {
useEffect(() => { const ok = await onSubmit(data, isNew);
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; if (ok) {
window.addEventListener("keydown", handler); onClose();
return () => window.removeEventListener("keydown", handler); } else {
}, [onClose]); setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
}
const set = (field: keyof Pick<RoleFormData, "name" | "description">) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm(p => ({ ...p, [field]: e.target.value }));
if (errors[field]) setErrors(p => ({ ...p, [field]: undefined }));
};
const togglePerm = (id: string) => {
setForm(p => ({
...p,
permissionIds: p.permissionIds.includes(id)
? p.permissionIds.filter(x => x !== id)
: [...p.permissionIds, id],
}));
}; };
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errs = await validate(form, isNew);
if (Object.keys(errs).length) { setErrors(errs); return; }
setSaving(true);
setApiError("");
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
};
const inputStyle = (field: keyof RoleFormErrors): React.CSSProperties => ({
...S.input,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="role-modal-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={isNew ? "دور جديد" : editRole?.name ?? ""}
style={{ subtitle={isNew ? "إضافة دور" : "تعديل دور"}
position: "fixed", inset: 0, zIndex: 50, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", size="md"
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", footer={
}} <>
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
إلغاء
</Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
{isNew ? "إنشاء الدور" : "حفظ التغييرات"}
</Button>
</>
}
> >
<div <form
onClick={e => e.stopPropagation()} id={FORM_ID}
style={{ onSubmit={handleSubmit(submitHandler)}
width: "100%", maxWidth: 600, noValidate
background: "var(--color-surface)", style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
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",
maxHeight: "90vh",
}}
> >
{/* Header */} {errors.name?.type === "manual" && (
<div style={{ <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
display: "flex", alignItems: "center", justifyContent: "space-between", )}
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)", <div dir="rtl">
background: "var(--color-surface-muted)", <Input
flexShrink: 0, label="اسم الدور *"
}}> {...register("name")}
<div> error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> placeholder="مثال: مدير الفروع"
{isNew ? "إضافة دور" : "تعديل دور"} dir="rtl"
</p> autoFocus
<h2 id="role-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> />
{isNew ? "دور جديد" : editRole?.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>
</div> </div>
{/* Body */} <div dir="rtl">
<form <Textarea
onSubmit={handleSubmit} noValidate label="الوصف"
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto", flex: 1 }} {...register("description")}
> placeholder="وصف مختصر لمهام هذا الدور…"
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />} error={errors.description?.message}
dir="rtl"
rows={3}
/>
</div>
{/* Name */} {/* Permissions — bound via Controller since it's a custom checkbox
<label style={S.label} dir="rtl"> group, not a plain input/select/textarea. */}
اسم الدور * {permissions.length > 0 && (
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="مثال: مدير الفروع" dir="rtl" /> <Controller
{errors.name && <span style={S.errorText}>{errors.name}</span>} name="permissionIds"
</label> control={control}
render={({ field }) => (
{/* Description */} <div dir="rtl">
<label style={S.label} dir="rtl"> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
الوصف <span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
<textarea الصلاحيات
style={{ </span>
...S.input, height: "auto", padding: "0.5rem 0.75rem", resize: "none", <div style={{ display: "flex", gap: 8 }}>
...(errors.description ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}), <Button
} as React.CSSProperties} type="button"
rows={3} onClick={() => setValue("permissionIds", permissions.map(x => x.id))}
value={form.description} style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}
onChange={set("description")} >
placeholder="وصف مختصر لمهام هذا الدور…" تحديد الكل
dir="rtl" </Button>
/> <Button
{errors.description && <span style={S.errorText}>{errors.description}</span>} type="button"
</label> onClick={() => setValue("permissionIds", [])}
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}
{/* Permissions */} >
{permissions.length > 0 && ( إلغاء الكل
<div dir="rtl"> </Button>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}> </div>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
الصلاحيات
</span>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: permissions.map(x => x.id) }))}
style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
تحديد الكل
</button>
<button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: [] }))}
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
إلغاء الكل
</button>
</div> </div>
<div style={{
border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)",
padding: "0.875rem", background: "var(--color-surface-muted)",
maxHeight: 280, overflowY: "auto",
}}>
{Object.entries(grouped).map(([module, perms]) => (
<PermissionGroup
key={module}
module={module}
perms={perms}
selected={field.value}
onToggle={(id) =>
field.onChange(
field.value.includes(id)
? field.value.filter((x) => x !== id)
: [...field.value, id],
)
}
/>
))}
</div>
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 6 }}>
{field.value.length} صلاحية محددة من أصل {permissions.length}
</p>
</div> </div>
<div style={{ )}
border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)", />
padding: "0.875rem", background: "var(--color-surface-muted)", )}
maxHeight: 280, overflowY: "auto", </form>
}}> </Modal>
{Object.entries(grouped).map(([module, perms]) => (
<PermissionGroup
key={module} module={module} perms={perms}
selected={form.permissionIds} onToggle={togglePerm}
/>
))}
</div>
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 6 }}>
{form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
</p>
</div>
)}
{/* Actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem", flexShrink: 0 }}>
<button type="button" onClick={onClose} disabled={saving}
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="submit" disabled={saving}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إنشاء الدور" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
); );
} }

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { Role } from "@/src/types/role"; import { Role } from "@/src/types/role";
import { Spinner } from "../UI"; import { Button, EmptyState, Spinner } from "../UI";
// ── Status badge ────────────────────────────────────────────────────────────── // ── 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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ <span style={{
@@ -23,6 +24,11 @@ function StatusBadge({ active }: { active: boolean }) {
} }
// ── Icon button ─────────────────────────────────────────────────────────────── // ── 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
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
// square icon chip. Forcing it here would misshape the button and collapse
// the view/edit/delete green/blue/red color-coding into one variant.
function IconBtn({ onClick, title, color, bg, borderColor, children }: { function IconBtn({ onClick, title, color, bg, borderColor, children }: {
onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode; onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode;
}) { }) {
@@ -90,21 +96,18 @@ export function RoleTable({
</div> </div>
) : roles.length === 0 ? ( ) : roles.length === 0 ? (
/* Empty state */ /* Empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> <EmptyState
<div style={{ fontSize: 40, marginBottom: 12 }}>🛡</div> icon="🛡️"
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: "0 0 8px" }}> title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"}
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"} description={!search ? "ابدأ بإنشاء أول دور في النظام" : undefined}
</p> action={
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: "0 0 16px" }}> !search && (
{!search && "ابدأ بإنشاء أول دور في النظام"} <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
</p> إضافة أول دور
{!search && ( </Button>
<button type="button" onClick={onAddFirst} )
style={{ fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}> }
إضافة أول دور />
</button>
)}
</div>
) : ( ) : (
/* Data rows */ /* Data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> <ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
@@ -166,15 +169,24 @@ export function RoleTable({
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong> صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
{[ <Button
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, type="button"
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, variant="secondary"
].map(btn => ( size="sm"
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled} onClick={() => onPageChange(Math.max(1, page - 1))}
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 }}> disabled={page === 1}
{btn.label} >
</button> السابق
))} </Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Spinner } from "../../UI"; import { Alert, Button, Modal, Spinner } from "../../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { archivedRoleService } from "@/src/services/archive/archivedRole.service"; import { archivedRoleService } from "@/src/services/archive/archivedRole.service";
import type { ArchivedRole } from "@/src/types/role"; import type { ArchivedRole } from "@/src/types/role";
@@ -12,6 +12,8 @@ interface ArchivedRoleDetailModalProps {
} }
// ── small helper components ─────────────────────────────────────────────────── // ── 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 }) { function DetailRow({ label, value }: { label: string; value?: string | null }) {
return ( return (
<div style={{ <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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ <span style={{
@@ -69,29 +73,22 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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 // fetch archived role details on mount
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const data = await archivedRoleService.getByIdUnwrapped(roleId, token); const data = await archivedRoleService.getByIdUnwrapped(roleId, token);
if (!cancelled) setRole(data); if (!cancelled) setRole(data);
} catch { } catch {
if (!cancelled) setError("تعذّر تحميل بيانات الدور المؤرشف. يرجى المحاولة لاحقاً."); if (!cancelled) setError("تعذّر تحميل بيانات الدور المؤرشف. يرجى المحاولة لاحقاً.");
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [roleId]); }, [roleId]);
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
@@ -99,135 +96,58 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
// ── render ──────────────────────────────────────────────────────────────── // ── render ────────────────────────────────────────────────────────────────
return ( return (
<div <Modal
role="dialog" aria-modal="true" aria-labelledby="archived-role-detail-title" open
onClick={e => { if (e.target === e.currentTarget) onClose(); }} title={role?.name ?? "عرض الدور"}
style={{ subtitle="دور مؤرشف"
position: "fixed", inset: 0, zIndex: 55, onClose={onClose}
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", size="md"
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", footer={
}} <Button type="button" variant="secondary" onClick={onClose}>
إغلاق
</Button>
}
> >
<div {/* loading */}
onClick={e => e.stopPropagation()} {loading && (
style={{ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
width: "100%", maxWidth: 480, <Spinner size="sm" className="text-blue-600" />
background: "var(--color-surface)", <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
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>
</div> </div>
)}
{/* ── body ── */} {/* error — fallback UI on API failure */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}> {!loading && error && <Alert type="error" message={error} />}
{/* loading */} {/* content */}
{loading && ( {!loading && role && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}> <div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error — fallback UI on API failure */} {/* icon + name + status row */}
{!loading && error && ( <div style={{
<div style={{ display: "flex", alignItems: "center", gap: "1rem",
padding: "1rem 1.25rem", padding: "0 0 1.25rem",
borderRadius: "var(--radius-lg)", borderBottom: "1px solid var(--color-border)",
background: "#FEF2F2", border: "1px solid #FECACA", marginBottom: "0.25rem",
fontSize: 13, color: "#991B1B", fontWeight: 500, }}>
textAlign: "center", <RoleIcon />
}}> <div style={{ flex: 1, minWidth: 0 }}>
{error} <p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
</div> <p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
)} #{role.id}
</p>
{/* content */} <div style={{ marginTop: 8 }}>
{!loading && role && ( <StatusBadge active={role.isActive} />
<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>
</div> </div>
{/* detail rows */}
<DetailRow label="الوصف" value={role.description} />
<DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />
</div> </div>
)} </div>
</div>
{/* ── footer ── */} {/* detail rows */}
<div style={{ <DetailRow label="الوصف" value={role.description} />
padding: "1rem 1.5rem", <DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
borderTop: "1px solid var(--color-border)", <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />
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>
</div> </div>
</div> )}
</div> </Modal>
); );
} }

View File

@@ -1,9 +1,11 @@
"use client"; "use client";
import { Spinner } from "../../UI"; import { Button, EmptyState, Spinner } from "../../UI";
import type { ArchivedRole } from "@/src/types/role"; import type { ArchivedRole } from "@/src/types/role";
// ── status badge ───────────────────────────────────────────────────────────── // ── 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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( 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" }}> <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 ────────────────────────────────────────────────────────────── // ── 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 }) { function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return ( return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }} <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> </div>
) : roles.length === 0 ? ( ) : roles.length === 0 ? (
/* empty state */ /* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> <EmptyState
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> icon="🛡️"
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."} title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
</p> />
</div>
) : ( ) : (
/* data rows */ /* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> <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> صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
{[ <Button
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, type="button"
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, variant="secondary"
].map(btn => ( size="sm"
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled} onClick={() => onPageChange(Math.max(1, page - 1))}
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)" }}> disabled={page === 1}
{btn.label} >
</button> السابق
))} </Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => onPageChange(Math.min(pages, page + 1))}
disabled={page === pages}
>
التالي
</Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "../../UI"; import { Alert, Input, Modal } from "../../UI";
import { ArchivedRoleTable } from "./ArchivedRoleTable"; import { ArchivedRoleTable } from "./ArchivedRoleTable";
import { ArchivedRoleDetailModal } from "./ArchivedRoleDetailModal"; import { ArchivedRoleDetailModal } from "./ArchivedRoleDetailModal";
import { useArchivedRoles } from "@/src/hooks/archive/useArchiveRole"; import { useArchivedRoles } from "@/src/hooks/archive/useArchiveRole";
@@ -10,6 +10,21 @@ interface ArchivedRolesModalProps {
onClose: () => void; onClose: () => void;
} }
const searchIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);
export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) { export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
const [viewRoleId, setViewRoleId] = useState<string | null>(null); const [viewRoleId, setViewRoleId] = useState<string | null>(null);
@@ -20,79 +35,28 @@ export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
} = useArchivedRoles(); } = useArchivedRoles();
return ( return (
<div <>
role="dialog" aria-modal="true" aria-labelledby="role-archive-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 70,
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
>
{viewRoleId && ( {viewRoleId && (
<ArchivedRoleDetailModal roleId={viewRoleId} onClose={() => setViewRoleId(null)} /> <ArchivedRoleDetailModal roleId={viewRoleId} onClose={() => setViewRoleId(null)} />
)} )}
<div <Modal
onClick={e => e.stopPropagation()} open
style={{ onClose={onClose}
width: "100%", maxWidth: 920, size="lg"
background: "var(--color-surface-sunken)", subtitle="الأرشيف"
borderRadius: "var(--radius-xl)", title={`الأدوار المؤرشفة (${total})`}
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
> >
{/* header */} <div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="role-archive-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
الأدوار المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</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>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search — client-side, since /role/archived has no search param */} {/* search — client-side, since /role/archived has no search param */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}> <div style={{ maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <Input
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> label="بحث"
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث باسم الدور..." placeholder="بحث باسم الدور..."
value={search} value={search}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
icon={searchIcon}
dir="rtl" dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/> />
</div> </div>
@@ -109,7 +73,7 @@ export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
onPageChange={setPage} onPageChange={setPage}
/> />
</div> </div>
</div> </Modal>
</div> </>
); );
} }