Compare commits
2 Commits
d23866d7fc
...
f1a521dd5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a521dd5a | ||
|
|
55f76005c9 |
@@ -1,46 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Button, Input, Modal } from "../UI";
|
||||
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 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 {};
|
||||
}
|
||||
}
|
||||
const FORM_ID = "branch-form";
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
interface BranchFormModalProps {
|
||||
@@ -53,7 +19,18 @@ interface BranchFormModalProps {
|
||||
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
|
||||
const isNew = editBranch === null;
|
||||
|
||||
const [form, setForm] = useState<BranchFormData>({
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<BranchFormData>({
|
||||
// Cast the schema itself and pin the generic explicitly on yupResolver —
|
||||
// create/update schemas differ structurally in which fields are required
|
||||
// (e.g. city/street are optional on update), so yup can't unify them
|
||||
// into the single flat BranchFormData shape on its own.
|
||||
resolver: yupResolver<BranchFormData>((isNew ? createBranchSchema : updateBranchSchema) as any),
|
||||
defaultValues: {
|
||||
name: editBranch?.name ?? "",
|
||||
email: editBranch?.email ?? "",
|
||||
phone: editBranch?.phone ?? "",
|
||||
@@ -67,195 +44,169 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
||||
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(); }, []);
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
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 submitHandler = async (data: BranchFormData) => {
|
||||
const ok = await onSubmit(data, isNew);
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="branch-modal-title"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? "فرع جديد" : editBranch?.name ?? ""}
|
||||
subtitle={isNew ? "إضافة فرع" : "تعديل فرع"}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||
{isNew ? "إضافة الفرع" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 560,
|
||||
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",
|
||||
maxHeight: "90vh",
|
||||
}}
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||
>
|
||||
{/* 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 }}>
|
||||
{isNew ? "إضافة فرع" : "تعديل فرع"}
|
||||
</p>
|
||||
<h2 id="branch-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{isNew ? "فرع جديد" : editBranch?.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>
|
||||
|
||||
{/* body */}
|
||||
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto" }}>
|
||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
||||
{errors.name?.type === "manual" && (
|
||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||
)}
|
||||
|
||||
{/* name */}
|
||||
<label style={S.label}>
|
||||
اسم الفرع *
|
||||
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="فرع الرياض" autoComplete="organization" dir="rtl" />
|
||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
||||
</label>
|
||||
<Input
|
||||
label="اسم الفرع *"
|
||||
{...register("name")}
|
||||
error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
|
||||
placeholder="فرع الرياض"
|
||||
autoComplete="organization"
|
||||
autoFocus
|
||||
dir="rtl"
|
||||
/>
|
||||
|
||||
{/* email + phone */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
البريد الإلكتروني
|
||||
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="branch@co.sa" autoComplete="email" dir="ltr" />
|
||||
{errors.email && <span style={S.errorText}>{errors.email}</span>}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
رقم الهاتف
|
||||
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" />
|
||||
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
|
||||
</label>
|
||||
<Input
|
||||
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>
|
||||
|
||||
{/* city + street */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
المدينة *
|
||||
<input style={inputStyle("city")} value={form.city} onChange={set("city")} placeholder="الرياض" dir="rtl" />
|
||||
{errors.city && <span style={S.errorText}>{errors.city}</span>}
|
||||
</label>
|
||||
<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>
|
||||
<Input
|
||||
label="المدينة *"
|
||||
{...register("city")}
|
||||
error={errors.city?.message}
|
||||
placeholder="الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
<Input
|
||||
label="الشارع *"
|
||||
{...register("street")}
|
||||
error={errors.street?.message}
|
||||
placeholder="شارع الملك فهد"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* state + district */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
المنطقة
|
||||
<input style={inputStyle("state")} value={form.state} onChange={set("state")} placeholder="منطقة الرياض" dir="rtl" />
|
||||
{errors.state && <span style={S.errorText}>{errors.state}</span>}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
الحي
|
||||
<input style={inputStyle("district")} value={form.district} onChange={set("district")} placeholder="حي العليا" dir="rtl" />
|
||||
{errors.district && <span style={S.errorText}>{errors.district}</span>}
|
||||
</label>
|
||||
<Input
|
||||
label="المنطقة"
|
||||
{...register("state")}
|
||||
error={errors.state?.message}
|
||||
placeholder="منطقة الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
<Input
|
||||
label="الحي"
|
||||
{...register("district")}
|
||||
error={errors.district?.message}
|
||||
placeholder="حي العليا"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* buildingNo + unitNo + zipCode */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
رقم المبنى
|
||||
<input style={inputStyle("buildingNo")} value={form.buildingNo} onChange={set("buildingNo")} placeholder="1234" dir="ltr" />
|
||||
{errors.buildingNo && <span style={S.errorText}>{errors.buildingNo}</span>}
|
||||
</label>
|
||||
<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>
|
||||
<Input
|
||||
label="رقم المبنى"
|
||||
{...register("buildingNo")}
|
||||
error={errors.buildingNo?.message}
|
||||
placeholder="1234"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="رقم الوحدة"
|
||||
{...register("unitNo")}
|
||||
error={errors.unitNo?.message}
|
||||
placeholder="5"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="الرمز البريدي"
|
||||
{...register("zipCode")}
|
||||
error={errors.zipCode?.message}
|
||||
placeholder="12345"
|
||||
dir="ltr"
|
||||
/>
|
||||
</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>
|
||||
<Input
|
||||
label="الدولة"
|
||||
{...register("country")}
|
||||
error={errors.country?.message}
|
||||
placeholder="SA"
|
||||
dir="ltr"
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<Input
|
||||
label="خط العرض (اختياري)"
|
||||
{...register("latitude")}
|
||||
error={errors.latitude?.message}
|
||||
placeholder="24.7136"
|
||||
dir="ltr"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<Input
|
||||
label="خط الطول (اختياري)"
|
||||
{...register("longitude")}
|
||||
error={errors.longitude?.message}
|
||||
placeholder="46.6753"
|
||||
dir="ltr"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Button, Input, Modal } from "../UI";
|
||||
import {
|
||||
createMaintenanceSchema,
|
||||
updateMaintenanceSchema,
|
||||
@@ -10,48 +10,16 @@ import {
|
||||
import type {
|
||||
CarMaintenance,
|
||||
CreateMaintenancePayload,
|
||||
MaintenanceFormErrors,
|
||||
UpdateMaintenancePayload,
|
||||
} from "@/src/types/carMaintanance";
|
||||
|
||||
// ── Shared input style ────────────────────────────────────────────────────────
|
||||
|
||||
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 FORM_ID = "maintenance-form";
|
||||
|
||||
// ── Number coercion helper ────────────────────────────────────────────────────
|
||||
// The backend can return `cost` as a numeric string (common with Decimal
|
||||
// columns getting JSON-serialized as strings), even though our TS type says
|
||||
// `number`. If that untouched value round-trips back out on update without
|
||||
// ever passing through the `<input type="number">` onChange handler, it
|
||||
// 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.
|
||||
// `number`. Coerce defensively both when hydrating the form AND right before
|
||||
// building the submit payload, so this can never slip through as a string.
|
||||
|
||||
function toNumberOrUndefined(v: unknown): number | 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;
|
||||
}
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────────────
|
||||
// <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`;
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CarMaintenanceFormModalProps {
|
||||
@@ -116,208 +73,121 @@ export function CarMaintenanceFormModal({
|
||||
}: CarMaintenanceFormModalProps) {
|
||||
const isNew = editRecord === null;
|
||||
|
||||
// ── Form state ─────────────────────────────────────────────────────────────
|
||||
const [reason, setReason] = useState(editRecord?.reason ?? "");
|
||||
// Coerced defensively: editRecord.cost may arrive as a numeric string from
|
||||
// the backend (e.g. a Decimal column serialized to JSON as "150").
|
||||
const [cost, setCost] = useState<number | undefined>(toNumberOrUndefined(editRecord?.cost));
|
||||
const [startAt, setStartAt] = useState(editRecord?.startAt?.slice(0, 10) ?? "");
|
||||
const [endAt, setEndAt] = useState(editRecord?.endAt?.slice(0, 10) ?? "");
|
||||
|
||||
const [errors, setErrors] = useState<MaintenanceFormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<MaintenanceFormValues>({
|
||||
// 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 MaintenanceFormValues on its own.
|
||||
resolver: yupResolver<MaintenanceFormValues>(
|
||||
(isNew ? createMaintenanceSchema : updateMaintenanceSchema) as any,
|
||||
),
|
||||
defaultValues: {
|
||||
reason: editRecord?.reason ?? "",
|
||||
// Coerced defensively: editRecord.cost may arrive as a numeric string
|
||||
// from the backend (e.g. a Decimal column serialized to JSON as "150").
|
||||
cost: toNumberOrUndefined(editRecord?.cost),
|
||||
startAt: editRecord?.startAt?.slice(0, 10) ?? "",
|
||||
endAt: editRecord?.endAt?.slice(0, 10) ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const clearFieldError = (field: keyof MaintenanceFormErrors) =>
|
||||
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||
const costField = register("cost", {
|
||||
setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)),
|
||||
});
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitHandler = async (data: MaintenanceFormValues) => {
|
||||
// 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
|
||||
// from a record whose `cost` came back as a numeric string).
|
||||
const safeCost = toNumberOrUndefined(cost);
|
||||
// somehow slipped back into a string.
|
||||
const safeCost = toNumberOrUndefined(data.cost);
|
||||
|
||||
// Build a snapshot for yup — include everything so optional rules also run.
|
||||
const formSnapshot: Partial<CreateMaintenancePayload> = {
|
||||
reason,
|
||||
cost: safeCost,
|
||||
...(startAt && { startAt }),
|
||||
...(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);
|
||||
// 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: data.reason, cost: safeCost };
|
||||
if (data.startAt) raw.startAt = toIsoDateTime(data.startAt);
|
||||
if (data.endAt) raw.endAt = toIsoDateTime(data.endAt);
|
||||
|
||||
const payload = raw as unknown as CreateMaintenancePayload;
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
const ok = await onSubmit(payload, isNew);
|
||||
setSaving(false);
|
||||
if (ok) onClose();
|
||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError("reason", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="maintenance-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: "1rem", overflowY: "auto",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
subtitle={isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"}
|
||||
title={carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||
{isNew ? "إضافة السجل" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 520,
|
||||
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",
|
||||
margin: "auto",
|
||||
}}
|
||||
>
|
||||
{/* 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 }}>
|
||||
{isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"}
|
||||
</p>
|
||||
<h2 id="maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
|
||||
</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>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }}
|
||||
dir="rtl"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
||||
{errors.reason?.type === "manual" && (
|
||||
<Alert type="error" message={errors.reason.message ?? ""} onClose={() => {}} />
|
||||
)}
|
||||
|
||||
<label style={labelStyle}>
|
||||
سبب الصيانة *
|
||||
<input
|
||||
ref={firstRef}
|
||||
style={inputStyle("reason")}
|
||||
value={reason}
|
||||
onChange={(e) => { setReason(e.target.value); clearFieldError("reason"); }}
|
||||
<Input
|
||||
label="سبب الصيانة *"
|
||||
{...register("reason")}
|
||||
error={errors.reason && errors.reason.type !== "manual" ? errors.reason.message : undefined}
|
||||
placeholder="تغيير زيت المحرك"
|
||||
dir="rtl"
|
||||
autoFocus
|
||||
/>
|
||||
{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")}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="التكلفة (ر.س) *"
|
||||
type="number"
|
||||
min={0}
|
||||
value={cost ?? ""}
|
||||
onChange={(e) => { setCost(parseNum(e.target.value)); clearFieldError("cost"); }}
|
||||
{...costField}
|
||||
error={errors.cost?.message}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.cost && <span style={errorTextStyle}>{errors.cost}</span>}
|
||||
</label>
|
||||
|
||||
<label style={labelStyle}>
|
||||
تاريخ البدء *
|
||||
<input
|
||||
style={inputStyle("startAt")}
|
||||
<Input
|
||||
label="تاريخ البدء *"
|
||||
type="date"
|
||||
value={startAt}
|
||||
onChange={(e) => { setStartAt(e.target.value); clearFieldError("startAt"); }}
|
||||
{...register("startAt")}
|
||||
error={errors.startAt?.message}
|
||||
/>
|
||||
{errors.startAt && <span style={errorTextStyle}>{errors.startAt}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={labelStyle}>
|
||||
تاريخ الانتهاء
|
||||
<input
|
||||
style={inputStyle("endAt")}
|
||||
<Input
|
||||
label="تاريخ الانتهاء"
|
||||
type="date"
|
||||
value={endAt}
|
||||
onChange={(e) => { setEndAt(e.target.value); clearFieldError("endAt"); }}
|
||||
{...register("endAt")}
|
||||
error={errors.endAt?.message}
|
||||
hint="اتركه فارغاً إذا كانت الصيانة ما زالت جارية."
|
||||
/>
|
||||
{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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { Alert, Button, Input, Modal, Select } from "../UI";
|
||||
import {
|
||||
createClientSchema,
|
||||
updateClientSchema,
|
||||
@@ -11,39 +10,7 @@ import {
|
||||
} from "@/src/validations/client.validator";
|
||||
import type { Client, ClientFormData } from "@/src/types/client";
|
||||
|
||||
// ── 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,
|
||||
};
|
||||
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
const FORM_ID = "client-form";
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientFormModalProps {
|
||||
@@ -59,7 +26,6 @@ interface ClientFormModalProps {
|
||||
export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) {
|
||||
const isNew = editClient === null;
|
||||
|
||||
// FIX 1: was `Schema` (capital S) — now correctly `schema`
|
||||
const {
|
||||
register,
|
||||
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 ok = await onSubmit(data, isNew);
|
||||
if (ok) {
|
||||
@@ -92,166 +52,70 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="client-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
subtitle={isNew ? "إضافة عميل" : "تعديل عميل"}
|
||||
title={isNew ? "عميل جديد" : editClient?.name ?? ""}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||
{isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{/* 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,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إضافة عميل" : "تعديل عميل"}
|
||||
</p>
|
||||
<h2
|
||||
id="client-modal-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
dir="rtl"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{errors.name?.type === "manual" && (
|
||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||
)}
|
||||
|
||||
<label style={S.label}>
|
||||
اسم العميل {isNew && "*"}
|
||||
<input
|
||||
{...register("name")}
|
||||
style={withError(!!errors.name)}
|
||||
<Input
|
||||
label={`اسم العميل ${isNew ? "*" : ""}`}
|
||||
placeholder="شركة لوجي فلو للتوصيل"
|
||||
autoComplete="organization"
|
||||
dir="rtl"
|
||||
error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
|
||||
{...register("name")}
|
||||
/>
|
||||
{errors.name && errors.name.type !== "manual" && (
|
||||
<span style={S.errorText}>{errors.name.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<label style={S.label}>
|
||||
البريد الإلكتروني
|
||||
<input
|
||||
{...register("email")}
|
||||
style={withError(!!errors.email)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="البريد الإلكتروني"
|
||||
type="email"
|
||||
placeholder="info@company.sa"
|
||||
autoComplete="email"
|
||||
dir="ltr"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email && (
|
||||
<span style={S.errorText}>{errors.email.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
رقم الهاتف {isNew && "*"}
|
||||
<input
|
||||
{...register("phone")}
|
||||
style={withError(!!errors.phone)}
|
||||
<Input
|
||||
label={`رقم الهاتف ${isNew ? "*" : ""}`}
|
||||
type="tel"
|
||||
placeholder="05xxxxxxxx"
|
||||
autoComplete="tel"
|
||||
dir="ltr"
|
||||
error={errors.phone?.message}
|
||||
{...register("phone")}
|
||||
/>
|
||||
{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" }}
|
||||
<Select
|
||||
label="نوع العميل"
|
||||
dir="rtl"
|
||||
error={errors.clientType?.message}
|
||||
{...register("clientType")}
|
||||
>
|
||||
<option value="">اختر النوع</option>
|
||||
{CLIENT_TYPES.map((t) => (
|
||||
@@ -259,88 +123,20 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
|
||||
{t === "Individual" ? "فرد" : "شركة"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.clientType && (
|
||||
<span style={S.errorText}>{errors.clientType.message}</span>
|
||||
)}
|
||||
</label>
|
||||
</Select>
|
||||
|
||||
{!isNew && (
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-[12px] font-semibold text-[var(--color-text-secondary)] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("isActive" as never)}
|
||||
defaultChecked={editClient?.isActive ?? true}
|
||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||
className="w-3.5 h-3.5 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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Button } from "../../UI";
|
||||
import { ArchivedClientTable } from "./ArchivedClientTable";
|
||||
import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal";
|
||||
import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients";
|
||||
@@ -63,36 +63,33 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
|
||||
</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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</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
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
type="text"
|
||||
placeholder="بحث بالاسم أو البريد الإلكتروني..."
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
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)",
|
||||
}}
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { Alert, Button, Input, Modal } from "../UI";
|
||||
import {
|
||||
createAddressSchema,
|
||||
updateAddressSchema,
|
||||
@@ -13,32 +12,9 @@ import {
|
||||
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||
|
||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||
// Only section-title styling is left here — every input/label/error visual
|
||||
// is now owned by the shared <Input /> component.
|
||||
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: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
@@ -50,11 +26,7 @@ const S = {
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...S.input,
|
||||
border: hasError ? "1px solid var(--color-danger)" : "1px solid var(--color-border)",
|
||||
background: hasError ? "#FEF2F2" : S.input.background,
|
||||
});
|
||||
const FORM_ID = "address-form";
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface AddressFormModalProps {
|
||||
@@ -138,12 +110,6 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
|
||||
location: locationErr = {},
|
||||
} = 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 ok = await onSubmit(data);
|
||||
if (ok) {
|
||||
@@ -154,89 +120,28 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="addr-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? "عنوان جديد" : editAddress?.label ?? ""}
|
||||
subtitle={isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||
{isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 560,
|
||||
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 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
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 }}>
|
||||
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
||||
</p>
|
||||
<h2
|
||||
id="addr-modal-title"
|
||||
style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}
|
||||
>
|
||||
{isNew ? "عنوان جديد" : editAddress?.label}
|
||||
</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>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||
>
|
||||
{errors.label?.type === "manual" && (
|
||||
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
|
||||
@@ -245,169 +150,148 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
|
||||
{/* Address Meta */}
|
||||
<p style={S.sectionTitle}>بيانات العنوان</p>
|
||||
|
||||
<label style={S.label}>
|
||||
نوع العنوان
|
||||
<input
|
||||
<Input
|
||||
label={`نوع العنوان${isNew ? " *" : ""}`}
|
||||
{...register("label")}
|
||||
style={withError(!!errors.label && errors.label.type !== "manual")}
|
||||
error={errors.label && errors.label.type !== "manual" ? errors.label.message : undefined}
|
||||
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>
|
||||
<Input
|
||||
label="اسم الفرع (اختياري)"
|
||||
{...register("branchName")}
|
||||
placeholder="فرع الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
|
||||
{/* Address Details */}
|
||||
<p style={S.sectionTitle}>تفاصيل العنوان</p>
|
||||
|
||||
<label style={S.label}>
|
||||
الشارع / العنوان التفصيلي {isNew && "*"}
|
||||
<input
|
||||
<Input
|
||||
label={`الشارع / العنوان التفصيلي${isNew ? " *" : ""}`}
|
||||
{...register("details.street")}
|
||||
style={withError(!!detailsErr.street)}
|
||||
error={detailsErr.street?.message}
|
||||
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>
|
||||
<Input
|
||||
label={`المدينة${isNew ? " *" : ""}`}
|
||||
{...register("details.city")}
|
||||
error={detailsErr.city?.message}
|
||||
placeholder="الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
<Input
|
||||
label="المنطقة"
|
||||
{...register("details.state")}
|
||||
error={detailsErr.state?.message}
|
||||
placeholder="منطقة الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
</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>
|
||||
<Input
|
||||
label="الحي (اختياري)"
|
||||
{...register("details.district")}
|
||||
placeholder="حي العليا"
|
||||
dir="rtl"
|
||||
/>
|
||||
<Input
|
||||
label="رقم المبنى (اختياري)"
|
||||
{...register("details.buildingNo")}
|
||||
placeholder="1234"
|
||||
dir="ltr"
|
||||
/>
|
||||
</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>
|
||||
<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" }}>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<label style={S.label}>
|
||||
الشقة / الطابق (اختياري)
|
||||
<input {...register("details.apartment")} style={S.input} placeholder="الطابق الثالث" dir="rtl" />
|
||||
</label>
|
||||
<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" }}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Button } from "../../UI";
|
||||
import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable";
|
||||
import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal";
|
||||
import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses";
|
||||
@@ -67,36 +67,33 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
|
||||
</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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{/* search — client-side only: this endpoint has no server-side search/pagination */}
|
||||
<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
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
type="text"
|
||||
placeholder="بحث بالنوع أو المدينة أو الفرع..."
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
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)",
|
||||
}}
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
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 { getStoredToken } from "@/src/lib/auth";
|
||||
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator";
|
||||
@@ -16,37 +16,8 @@ import type {
|
||||
DriverStatus,
|
||||
} from "@/src/types/driver";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
import { FileInput } from "@/src/Components/Driver/DriverFormModalHelper";
|
||||
|
||||
// ── Shared input style ───────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
// ── Shared bits ──────────────────────────────────────────────────────────────
|
||||
|
||||
const sectionHeadingStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
@@ -57,12 +28,7 @@ const sectionHeadingStyle: React.CSSProperties = {
|
||||
margin: "0.5rem 0 0",
|
||||
};
|
||||
|
||||
const optionalLabelStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-hint)",
|
||||
marginRight: 4,
|
||||
};
|
||||
const FORM_ID = "driver-form";
|
||||
|
||||
// ── Date helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -144,9 +110,12 @@ export function DriverFormModal({
|
||||
handleSubmit,
|
||||
control,
|
||||
setError,
|
||||
setFocus,
|
||||
formState: { errors, isSubmitting },
|
||||
} = 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: {
|
||||
name: editDriver?.name ?? "",
|
||||
phone: editDriver?.phone ?? "",
|
||||
@@ -174,23 +143,12 @@ export function DriverFormModal({
|
||||
});
|
||||
|
||||
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(() => {
|
||||
firstRef.current?.focus();
|
||||
}, []);
|
||||
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" } : {}),
|
||||
});
|
||||
setFocus("name");
|
||||
}, [setFocus]);
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -240,67 +198,29 @@ export function DriverFormModal({
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="driver-modal-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: "1rem", overflowY: "auto",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
|
||||
title={isNew ? "سائق جديد" : editDriver?.name ?? ""}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||
{isNew ? "إضافة السائق" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 640,
|
||||
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", margin: "auto",
|
||||
}}
|
||||
>
|
||||
{/* 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 }}>
|
||||
{isNew ? "إضافة سائق" : "تعديل سائق"}
|
||||
</p>
|
||||
<h2 id="driver-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{isNew ? "سائق جديد" : editDriver?.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>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex", flexDirection: "column", gap: "1rem",
|
||||
maxHeight: "75vh", overflowY: "auto",
|
||||
}}
|
||||
dir="rtl"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{errors.name?.type === "manual" && (
|
||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} />
|
||||
@@ -309,235 +229,184 @@ export function DriverFormModal({
|
||||
{/* ── Section: Personal Info ── */}
|
||||
<p style={sectionHeadingStyle}>البيانات الشخصية</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
{/* Name — required */}
|
||||
<label style={labelStyle}>
|
||||
الاسم الكامل *
|
||||
<input
|
||||
ref={firstRef}
|
||||
style={withError(!!errors.name && errors.name.type !== "manual")}
|
||||
{...register("name")}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="الاسم الكامل *"
|
||||
placeholder="محمد عبدالله"
|
||||
dir="rtl"
|
||||
autoComplete="off"
|
||||
error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
|
||||
{...register("name")}
|
||||
/>
|
||||
{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")}
|
||||
<Input
|
||||
label="رقم الجوال *"
|
||||
placeholder="05XXXXXXXX"
|
||||
dir="ltr"
|
||||
error={errors.phone?.message}
|
||||
{...register("phone")}
|
||||
/>
|
||||
{errors.phone && <span style={errorTextStyle}>{errors.phone.message}</span>}
|
||||
</label>
|
||||
|
||||
{/* Email — optional */}
|
||||
<label style={labelStyle}>
|
||||
البريد الإلكتروني
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
style={withError(!!errors.email)}
|
||||
<Input
|
||||
label="البريد الإلكتروني (اختياري)"
|
||||
type="email"
|
||||
{...register("email")}
|
||||
placeholder="example@mail.com"
|
||||
dir="ltr"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
{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")}
|
||||
<Input
|
||||
label="الجنسية (اختياري)"
|
||||
placeholder="سعودي"
|
||||
dir="rtl"
|
||||
error={errors.nationality?.message}
|
||||
{...register("nationality")}
|
||||
/>
|
||||
{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")}
|
||||
<Input
|
||||
className="sm:col-span-2"
|
||||
label="العنوان (اختياري)"
|
||||
placeholder="الرياض، حي..."
|
||||
dir="rtl"
|
||||
error={errors.address?.message}
|
||||
{...register("address")}
|
||||
/>
|
||||
{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")}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Select
|
||||
label="نوع الهوية (اختياري)"
|
||||
dir="rtl"
|
||||
error={errors.nationalIdType?.message}
|
||||
{...register("nationalIdType")}
|
||||
>
|
||||
<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>
|
||||
</Select>
|
||||
|
||||
{/* National ID number */}
|
||||
<label style={labelStyle}>
|
||||
رقم الهوية
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} {...register("nationalId")} placeholder="1XXXXXXXXX" dir="ltr" />
|
||||
</label>
|
||||
<Input
|
||||
label="رقم الهوية (اختياري)"
|
||||
placeholder="1XXXXXXXXX"
|
||||
dir="ltr"
|
||||
error={errors.nationalId?.message}
|
||||
{...register("nationalId")}
|
||||
/>
|
||||
|
||||
{/* National ID expiry */}
|
||||
<label style={labelStyle}>
|
||||
انتهاء الهوية
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} type="date" {...register("nationalIdExpiry")} />
|
||||
</label>
|
||||
<Input
|
||||
label="انتهاء الهوية (اختياري)"
|
||||
type="date"
|
||||
error={errors.nationalIdExpiry?.message}
|
||||
{...register("nationalIdExpiry")}
|
||||
/>
|
||||
|
||||
{/* GOSI — optional */}
|
||||
<label style={labelStyle}>
|
||||
رقم GOSI
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} {...register("gosiNumber")} placeholder="GOSI-XXXX" dir="ltr" />
|
||||
</label>
|
||||
<Input
|
||||
label="رقم GOSI (اختياري)"
|
||||
placeholder="GOSI-XXXX"
|
||||
dir="ltr"
|
||||
error={errors.gosiNumber?.message}
|
||||
{...register("gosiNumber")}
|
||||
/>
|
||||
</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")}
|
||||
<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")}
|
||||
/>
|
||||
{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>
|
||||
<Input
|
||||
label="نوع الرخصة (اختياري)"
|
||||
placeholder="خاص / عام"
|
||||
dir="rtl"
|
||||
error={errors.licenseType?.message}
|
||||
{...register("licenseType")}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<Input
|
||||
label="انتهاء الرخصة (اختياري)"
|
||||
type="date"
|
||||
error={errors.licenseExpiry?.message}
|
||||
{...register("licenseExpiry")}
|
||||
/>
|
||||
</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>
|
||||
<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")}
|
||||
/>
|
||||
|
||||
{/* Card type — optional */}
|
||||
<label style={labelStyle}>
|
||||
نوع البطاقة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<select
|
||||
style={{ ...withError(!!errors.driverCardType), cursor: "pointer" }}
|
||||
{...register("driverCardType")}
|
||||
<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>
|
||||
{errors.driverCardType && <span style={errorTextStyle}>{errors.driverCardType.message}</span>}
|
||||
</label>
|
||||
</Select>
|
||||
|
||||
{/* Card expiry — optional, validated if provided */}
|
||||
<label style={labelStyle}>
|
||||
انتهاء البطاقة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
style={withError(!!errors.driverCardExpiry)}
|
||||
<Input
|
||||
label="انتهاء البطاقة (اختياري)"
|
||||
type="date"
|
||||
error={errors.driverCardExpiry?.message}
|
||||
{...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")}
|
||||
<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>
|
||||
{errors.branchId && <span style={errorTextStyle}>{errors.branchId.message}</span>}
|
||||
</label>
|
||||
</Select>
|
||||
|
||||
{/* Driver type — optional */}
|
||||
<label style={labelStyle}>
|
||||
نوع السائق
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} {...register("driverType")} placeholder="رئيسي / احتياطي" dir="rtl" />
|
||||
</label>
|
||||
<Input
|
||||
label="نوع السائق (اختياري)"
|
||||
placeholder="رئيسي / احتياطي"
|
||||
dir="rtl"
|
||||
error={errors.driverType?.message}
|
||||
{...register("driverType")}
|
||||
/>
|
||||
|
||||
{/* Status — edit only */}
|
||||
{!isNew && (
|
||||
<label style={labelStyle}>
|
||||
الحالة
|
||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("status")} dir="rtl">
|
||||
<Select label="الحالة" dir="rtl" {...register("status")}>
|
||||
<option value="Active">نشط</option>
|
||||
<option value="InTrip">في رحلة</option>
|
||||
<option value="Inactive">غير نشط</option>
|
||||
<option value="Suspended">موقوف</option>
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -547,7 +416,7 @@ export function DriverFormModal({
|
||||
جميع حقول الصور اختيارية
|
||||
</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{/* File inputs cannot use register() directly — bind via Controller
|
||||
to the existing FileInput component's current/onChange props. */}
|
||||
<Controller
|
||||
@@ -572,45 +441,7 @@ export function DriverFormModal({
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedDriverTable } from "./ArchivedDriverTable";
|
||||
import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal";
|
||||
import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers";
|
||||
@@ -10,6 +10,21 @@ interface ArchivedDriversProps {
|
||||
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
|
||||
* useArchivedDrivers hook to the table and detail modal, mirroring
|
||||
@@ -25,79 +40,28 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
|
||||
} = useArchivedDrivers();
|
||||
|
||||
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 && (
|
||||
<ArchivedDriverDetailModal driverId={viewDriverId} onClose={() => setViewDriverId(null)} />
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 920,
|
||||
background: "var(--color-surface-sunken)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
subtitle="الأرشيف"
|
||||
title={`السائقون المؤرشفون (${total})`}
|
||||
>
|
||||
{/* header */}
|
||||
<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" }}>
|
||||
<div dir="rtl" style={{ 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"
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
placeholder="بحث بالاسم أو الهاتف..."
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
icon={searchIcon}
|
||||
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>
|
||||
|
||||
@@ -113,7 +77,7 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Alert, Button, Input } from "../UI";
|
||||
import { driverService } from "@/src/services/driver.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
|
||||
@@ -65,20 +65,8 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 140 }}>
|
||||
<label
|
||||
htmlFor="report-date"
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-muted)",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
تاريخ التقرير
|
||||
</label>
|
||||
<input
|
||||
id="report-date"
|
||||
<Input
|
||||
label="تاريخ التقرير"
|
||||
type="date"
|
||||
value={date}
|
||||
max={today}
|
||||
@@ -86,64 +74,21 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||
setDate(e.target.value);
|
||||
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>
|
||||
|
||||
<button
|
||||
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" />}
|
||||
<Button onClick={handleGenerate} disabled={!date} loading={loading}>
|
||||
{loading ? "جارٍ الإنشاء…" : "إنشاء التقرير"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
background: "#FEF2F2",
|
||||
border: "1px solid #FECACA",
|
||||
padding: "0.625rem 0.875rem",
|
||||
fontSize: 12,
|
||||
color: "#DC2626",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
⚠ {error}
|
||||
</div>
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
onClose={() => setError(null)}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 { clientService } from "@/src/services/client.service";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
@@ -22,34 +22,6 @@ import type {
|
||||
import type { Client } from "@/src/types/client";
|
||||
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 = {
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.25em",
|
||||
@@ -59,19 +31,12 @@ const sectionHeadingStyle: React.CSSProperties = {
|
||||
margin: "0.5rem 0 0",
|
||||
};
|
||||
|
||||
const optionalLabelStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-hint)",
|
||||
marginRight: 4,
|
||||
};
|
||||
|
||||
const createLinkStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
color: "var(--color-brand-600)",
|
||||
textDecoration: "none",
|
||||
fontWeight: 500,
|
||||
marginTop: 2,
|
||||
marginTop: 6,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
@@ -147,9 +112,12 @@ export function OrderFormModal({
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setFocus,
|
||||
formState: { errors, isSubmitting },
|
||||
} = 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: {
|
||||
shipmentNumber: editOrder?.shipmentNumber ?? "",
|
||||
recipientName: editOrder?.recipientName ?? "",
|
||||
@@ -200,7 +168,12 @@ export function OrderFormModal({
|
||||
});
|
||||
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
@@ -227,9 +200,6 @@ export function OrderFormModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
firstRef.current?.focus();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -238,11 +208,6 @@ export function OrderFormModal({
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...inputBase,
|
||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
const numberField = (
|
||||
field:
|
||||
| "quantity"
|
||||
@@ -340,79 +305,28 @@ export function OrderFormModal({
|
||||
const pickupErr = errors.pickupAddress;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-modal-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? "طلب جديد" : editOrder?.shipmentNumber ?? ""}
|
||||
subtitle={isNew ? "إنشاء طلب" : "تعديل طلب"}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form="order-form" loading={isSubmitting}>
|
||||
{isNew ? "إنشاء الطلب" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 640,
|
||||
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",
|
||||
margin: "auto",
|
||||
}}
|
||||
>
|
||||
<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 }}>
|
||||
{isNew ? "إنشاء طلب" : "تعديل طلب"}
|
||||
</p>
|
||||
<h2
|
||||
id="order-modal-title"
|
||||
style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0", fontFamily: "var(--font-mono)" }}
|
||||
>
|
||||
{isNew ? "طلب جديد" : editOrder?.shipmentNumber}
|
||||
</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>
|
||||
|
||||
<form
|
||||
id="order-form"
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||
dir="rtl"
|
||||
>
|
||||
{errors.shipmentNumber?.type === "manual" && (
|
||||
@@ -422,28 +336,29 @@ export function OrderFormModal({
|
||||
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={labelStyle}>
|
||||
رقم الشحنة *
|
||||
<input
|
||||
ref={firstRef}
|
||||
style={withError(!!errors.shipmentNumber && errors.shipmentNumber.type !== "manual")}
|
||||
<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"
|
||||
/>
|
||||
{errors.shipmentNumber && errors.shipmentNumber.type !== "manual" && (
|
||||
<span style={errorTextStyle}>{errors.shipmentNumber.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={labelStyle}>
|
||||
العميل *
|
||||
<select
|
||||
style={{ ...withError(!!errors.clientId), cursor: relLoading ? "wait" : "pointer" }}
|
||||
<div>
|
||||
<Select
|
||||
id="order-clientId"
|
||||
label="العميل *"
|
||||
error={errors.clientId?.message}
|
||||
disabled={relLoading}
|
||||
{...register("clientId")}
|
||||
dir="rtl"
|
||||
style={{ cursor: relLoading ? "wait" : "pointer" }}
|
||||
>
|
||||
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر العميل"}</option>
|
||||
{clients.map((c) => (
|
||||
@@ -452,8 +367,7 @@ export function OrderFormModal({
|
||||
{c.phone ? ` — ${c.phone}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.clientId && <span style={errorTextStyle}>{errors.clientId.message}</span>}
|
||||
</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" />
|
||||
@@ -461,28 +375,35 @@ export function OrderFormModal({
|
||||
</svg>
|
||||
إنشاء عميل جديد
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={labelStyle}>
|
||||
اسم المستلم *
|
||||
<input style={withError(!!errors.recipientName)} {...register("recipientName")} placeholder="أحمد محمد" dir="rtl" />
|
||||
{errors.recipientName && <span style={errorTextStyle}>{errors.recipientName.message}</span>}
|
||||
</label>
|
||||
<Input
|
||||
id="order-recipientName"
|
||||
label="اسم المستلم *"
|
||||
error={errors.recipientName?.message}
|
||||
{...register("recipientName")}
|
||||
placeholder="أحمد محمد"
|
||||
dir="rtl"
|
||||
/>
|
||||
|
||||
<label style={labelStyle}>
|
||||
رقم جوال المستلم *
|
||||
<input style={withError(!!errors.recipientPhone)} {...register("recipientPhone")} placeholder="05XXXXXXXX" dir="ltr" />
|
||||
{errors.recipientPhone && <span style={errorTextStyle}>{errors.recipientPhone.message}</span>}
|
||||
</label>
|
||||
<Input
|
||||
id="order-recipientPhone"
|
||||
label="رقم جوال المستلم *"
|
||||
error={errors.recipientPhone?.message}
|
||||
{...register("recipientPhone")}
|
||||
placeholder="05XXXXXXXX"
|
||||
dir="ltr"
|
||||
/>
|
||||
|
||||
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
|
||||
{isNew ? "الرحلة *" : "الرحلة"}
|
||||
{!isNew && <span style={optionalLabelStyle}>(اختياري)</span>}
|
||||
<select
|
||||
style={{ ...withError(!!errors.tripId), cursor: relLoading ? "wait" : "pointer" }}
|
||||
<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) => (
|
||||
@@ -492,8 +413,7 @@ export function OrderFormModal({
|
||||
{t.driver?.name ? ` (${t.driver.name})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.tripId && <span style={errorTextStyle}>{errors.tripId.message}</span>}
|
||||
</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" />
|
||||
@@ -501,277 +421,255 @@ export function OrderFormModal({
|
||||
</svg>
|
||||
إنشاء رحلة جديدة
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
<Input
|
||||
id="order-type"
|
||||
label="نوع الشحنة (اختياري)"
|
||||
{...register("type")}
|
||||
placeholder="عادي / مبرد"
|
||||
dir="rtl"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
id="order-quantity"
|
||||
label="الكمية *"
|
||||
type="number"
|
||||
min={1}
|
||||
error={errors.quantity?.message}
|
||||
{...numberField("quantity")}
|
||||
dir="ltr"
|
||||
/>
|
||||
|
||||
<label style={labelStyle}>
|
||||
الوزن (كجم)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input type="number" min={0} step="0.01" style={inputBase} {...numberField("weight")} dir="ltr" />
|
||||
</label>
|
||||
<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" }}>
|
||||
<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>
|
||||
<Input
|
||||
id="order-subTotal"
|
||||
label="الإجمالي الفرعي (اختياري)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
error={errors.subTotal?.message}
|
||||
{...numberField("subTotal")}
|
||||
dir="ltr"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
id="order-vatRate"
|
||||
label="نسبة الضريبة (%) (اختياري)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
error={errors.vatRate?.message}
|
||||
{...numberField("vatRate")}
|
||||
dir="ltr"
|
||||
/>
|
||||
|
||||
<label style={labelStyle}>
|
||||
طريقة الدفع
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("paymentMethod")} dir="rtl">
|
||||
<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>
|
||||
</label>
|
||||
</Select>
|
||||
|
||||
{!isNew && (
|
||||
<label style={labelStyle}>
|
||||
حالة الدفع
|
||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("paymentStatus")} dir="rtl">
|
||||
<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>
|
||||
</label>
|
||||
</Select>
|
||||
)}
|
||||
</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
|
||||
<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"
|
||||
style={withError(!!deliveryErr?.location?.coordinates)}
|
||||
error={deliveryErr?.location?.coordinates ? " " : undefined}
|
||||
{...coordField("deliveryAddress.location.coordinates.0")}
|
||||
placeholder="46.6753"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
خط العرض (Latitude) *
|
||||
<input
|
||||
<Input
|
||||
id="delivery-lat"
|
||||
label="خط العرض (Latitude) *"
|
||||
type="number"
|
||||
step="any"
|
||||
style={withError(!!deliveryErr?.location?.coordinates)}
|
||||
error={deliveryErr?.location?.coordinates?.message}
|
||||
{...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
|
||||
<Button
|
||||
key={m}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={pickupMode === m ? "primary" : "secondary"}
|
||||
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>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pickupMode === "id" ? (
|
||||
<label style={labelStyle}>
|
||||
معرّف العنوان (MongoDB ID)
|
||||
<input
|
||||
style={inputBase}
|
||||
<Input
|
||||
id="pickup-addressId"
|
||||
label="معرّف العنوان (MongoDB ID)"
|
||||
{...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
|
||||
<Input
|
||||
id="pickup-city"
|
||||
label="المدينة (اختياري)"
|
||||
{...register("pickupAddress.details.city")}
|
||||
placeholder="جدة"
|
||||
dir="rtl"
|
||||
/>
|
||||
<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"
|
||||
style={withError(!!pickupErr?.location?.coordinates)}
|
||||
error={pickupErr?.location?.coordinates ? " " : undefined}
|
||||
{...coordField("pickupAddress.location.coordinates.0")}
|
||||
placeholder="46.6753"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
خط العرض (Latitude)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
<Input
|
||||
id="pickup-lat"
|
||||
label="خط العرض (Latitude) (اختياري)"
|
||||
type="number"
|
||||
step="any"
|
||||
style={withError(!!pickupErr?.location?.coordinates)}
|
||||
error={pickupErr?.location?.coordinates?.message}
|
||||
{...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"
|
||||
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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedOrderTable } from "./ArchivedOrderTable";
|
||||
import { ArchivedOrderDetailModal } from "./ArchivedOrderDetailModal";
|
||||
import { useArchivedOrders } from "@/src/hooks/archive/useArchivedOrders";
|
||||
@@ -11,6 +11,21 @@ interface ArchivedOrdersModalProps {
|
||||
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) {
|
||||
const [viewOrder, setViewOrder] = useState<ArchivedOrder | null>(null);
|
||||
|
||||
@@ -21,79 +36,28 @@ export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
||||
} = useArchivedOrders();
|
||||
|
||||
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 && (
|
||||
<ArchivedOrderDetailModal order={viewOrder} onClose={() => setViewOrder(null)} />
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 920,
|
||||
background: "var(--color-surface-sunken)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
subtitle="الأرشيف"
|
||||
title={`الطلبات المؤرشفة (${total})`}
|
||||
>
|
||||
{/* header */}
|
||||
<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" }}>
|
||||
<div dir="rtl" style={{ 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"
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
placeholder="رقم الشحنة أو المستلم..."
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
icon={searchIcon}
|
||||
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>
|
||||
|
||||
@@ -109,7 +73,7 @@ export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 {
|
||||
createTripSchema,
|
||||
@@ -22,34 +22,8 @@ import type { CarOption } from "@/src/types/car";
|
||||
import type { BranchOption } from "@/src/types/branch";
|
||||
|
||||
// ── Shared styles ────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
// Only for section headings — everything field-level now comes from the
|
||||
// Input / Select / Textarea components themselves.
|
||||
|
||||
const sectionHeadingStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
@@ -60,13 +34,6 @@ const sectionHeadingStyle: React.CSSProperties = {
|
||||
margin: "0.5rem 0 0",
|
||||
};
|
||||
|
||||
const optionalLabelStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-hint)",
|
||||
marginRight: 4,
|
||||
};
|
||||
|
||||
// ── Form values shape ────────────────────────────────────────────────────────
|
||||
// startTime/endTime store the ISO-8601 string (via setValueAs on register),
|
||||
// while the <input type="datetime-local"> DOM element itself keeps showing
|
||||
@@ -127,9 +94,12 @@ export function TripFormModal({
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setFocus,
|
||||
formState: { errors, isSubmitting },
|
||||
} = 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: {
|
||||
title: editTrip?.title ?? "",
|
||||
driverId: editTrip?.driverId ?? "",
|
||||
@@ -150,11 +120,13 @@ export function TripFormModal({
|
||||
});
|
||||
|
||||
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(() => {
|
||||
firstRef.current?.focus();
|
||||
}, []);
|
||||
setFocus("title");
|
||||
}, [setFocus]);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -163,11 +135,6 @@ export function TripFormModal({
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...inputBase,
|
||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
const numberField = (
|
||||
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
|
||||
) =>
|
||||
@@ -182,6 +149,9 @@ export function TripFormModal({
|
||||
setValueAs: (v) => (v ? `${v}:00.000Z` : ""),
|
||||
});
|
||||
|
||||
const titleError =
|
||||
errors.title && errors.title.type !== "manual" ? errors.title.message : undefined;
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
|
||||
const submitHandler = async (data: TripFormValues) => {
|
||||
@@ -273,22 +243,19 @@ export function TripFormModal({
|
||||
>
|
||||
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
|
||||
</h2>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--color-text-muted)",
|
||||
padding: 4,
|
||||
}}
|
||||
aria-label="إغلاق"
|
||||
className="!px-1"
|
||||
>
|
||||
<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="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
@@ -310,208 +277,182 @@ export function TripFormModal({
|
||||
<p style={sectionHeadingStyle}>أساسيات الرحلة</p>
|
||||
|
||||
{/* Title */}
|
||||
<label style={labelStyle}>
|
||||
عنوان الرحلة <span style={{ color: "var(--color-danger)" }}>*</span>
|
||||
<input
|
||||
ref={firstRef}
|
||||
style={withError(!!errors.title && errors.title.type !== "manual")}
|
||||
<Input
|
||||
label="عنوان الرحلة *"
|
||||
error={titleError}
|
||||
{...register("title")}
|
||||
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" }}>
|
||||
{/* Driver */}
|
||||
<label style={labelStyle}>
|
||||
السائق <span style={{ color: "var(--color-danger)" }}>*</span>
|
||||
<select style={{ ...withError(!!errors.driverId), cursor: "pointer" }} {...register("driverId")} dir="rtl">
|
||||
<Select
|
||||
label="السائق *"
|
||||
error={errors.driverId?.message}
|
||||
{...register("driverId")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر السائق</option>
|
||||
{drivers.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name} — {d.phone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.driverId && <span style={errorTextStyle}>{errors.driverId.message}</span>}
|
||||
</label>
|
||||
</Select>
|
||||
|
||||
{/* Car */}
|
||||
<label style={labelStyle}>
|
||||
السيارة <span style={{ color: "var(--color-danger)" }}>*</span>
|
||||
<select style={{ ...withError(!!errors.carId), cursor: "pointer" }} {...register("carId")} dir="rtl">
|
||||
<Select
|
||||
label="السيارة *"
|
||||
error={errors.carId?.message}
|
||||
{...register("carId")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر السيارة</option>
|
||||
{cars.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.manufacturer} {c.model} — {c.plateNumber}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.carId && <span style={errorTextStyle}>{errors.carId.message}</span>}
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
{/* Branch */}
|
||||
<label style={labelStyle}>
|
||||
الفرع <span style={{ color: "var(--color-danger)" }}>*</span>
|
||||
<select style={{ ...withError(!!errors.branchId), cursor: "pointer" }} {...register("branchId")} dir="rtl">
|
||||
<Select
|
||||
label="الفرع *"
|
||||
error={errors.branchId?.message}
|
||||
{...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>
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<label style={labelStyle}>
|
||||
الحالة
|
||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("status")} dir="rtl">
|
||||
<Select label="الحالة" {...register("status")} dir="rtl">
|
||||
<option value="Scheduled">مجدولة</option>
|
||||
<option value="InProgress">جارية</option>
|
||||
<option value="Completed">مكتملة</option>
|
||||
<option value="Cancelled">ملغاة</option>
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ── Section: التوقيت ── */}
|
||||
<p style={sectionHeadingStyle}>التوقيت</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={labelStyle}>
|
||||
وقت البدء <span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input type="datetime-local" style={withError(!!errors.startTime)} {...dateTimeField("startTime")} />
|
||||
{errors.startTime && <span style={errorTextStyle}>{errors.startTime.message}</span>}
|
||||
</label>
|
||||
|
||||
<label style={labelStyle}>
|
||||
وقت الانتهاء <span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input type="datetime-local" style={withError(!!errors.endTime)} {...dateTimeField("endTime")} />
|
||||
{errors.endTime && <span style={errorTextStyle}>{errors.endTime.message}</span>}
|
||||
</label>
|
||||
<Input
|
||||
label="وقت البدء"
|
||||
hint="اختياري"
|
||||
type="datetime-local"
|
||||
error={errors.startTime?.message}
|
||||
{...dateTimeField("startTime")}
|
||||
/>
|
||||
<Input
|
||||
label="وقت الانتهاء"
|
||||
hint="اختياري"
|
||||
type="datetime-local"
|
||||
error={errors.endTime?.message}
|
||||
{...dateTimeField("endTime")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Section: الأعداد والمبالغ ── */}
|
||||
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={labelStyle}>
|
||||
المجمّع <span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input type="number" min="0" style={withError(!!errors.collectedCount)} {...numberField("collectedCount")} placeholder="0" dir="ltr" />
|
||||
{errors.collectedCount && <span style={errorTextStyle}>{errors.collectedCount.message}</span>}
|
||||
</label>
|
||||
|
||||
<label style={labelStyle}>
|
||||
المُسلَّم <span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input type="number" min="0" style={withError(!!errors.deliveredCount)} {...numberField("deliveredCount")} placeholder="0" dir="ltr" />
|
||||
{errors.deliveredCount && <span style={errorTextStyle}>{errors.deliveredCount.message}</span>}
|
||||
</label>
|
||||
|
||||
<label style={labelStyle}>
|
||||
المُرتجع <span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input type="number" min="0" style={withError(!!errors.returnedCount)} {...numberField("returnedCount")} placeholder="0" dir="ltr" />
|
||||
{errors.returnedCount && <span style={errorTextStyle}>{errors.returnedCount.message}</span>}
|
||||
</label>
|
||||
|
||||
<label style={labelStyle}>
|
||||
النقد المحصّل <span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input type="number" min="0" step="0.01" style={withError(!!errors.totalCashCollected)} {...numberField("totalCashCollected")} placeholder="0.00" dir="ltr" />
|
||||
{errors.totalCashCollected && <span style={errorTextStyle}>{errors.totalCashCollected.message}</span>}
|
||||
</label>
|
||||
<Input
|
||||
label="المجمّع"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
error={errors.collectedCount?.message}
|
||||
{...numberField("collectedCount")}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="المُسلَّم"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
error={errors.deliveredCount?.message}
|
||||
{...numberField("deliveredCount")}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
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>
|
||||
|
||||
{/* ── Section: ملاحظات ── */}
|
||||
<p style={sectionHeadingStyle}>ملاحظات</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={labelStyle}>
|
||||
ملاحظات <span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<textarea
|
||||
style={{ ...withError(!!errors.notes), height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }}
|
||||
<Textarea
|
||||
label="ملاحظات"
|
||||
hint="اختياري"
|
||||
error={errors.notes?.message}
|
||||
{...register("notes")}
|
||||
placeholder="أي ملاحظات إضافية…"
|
||||
dir="rtl"
|
||||
className="h-[72px]"
|
||||
/>
|
||||
{errors.notes && <span style={errorTextStyle}>{errors.notes.message}</span>}
|
||||
</label>
|
||||
|
||||
<label style={labelStyle}>
|
||||
سبب الإنهاء <span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<textarea
|
||||
style={{ ...withError(!!errors.endReason), height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }}
|
||||
<Textarea
|
||||
label="سبب الإنهاء"
|
||||
hint="اختياري"
|
||||
error={errors.endReason?.message}
|
||||
{...register("endReason")}
|
||||
placeholder="سبب إنهاء أو إلغاء الرحلة…"
|
||||
dir="rtl"
|
||||
className="h-[72px]"
|
||||
/>
|
||||
{errors.endReason && <span style={errorTextStyle}>{errors.endReason.message}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* reason — edit only (for reassigning driver/car) */}
|
||||
{!isNew && (
|
||||
<>
|
||||
<p style={sectionHeadingStyle}>سجل التغيير</p>
|
||||
<label style={labelStyle}>
|
||||
سبب التعديل{" "}
|
||||
<span style={optionalLabelStyle}>(مطلوب عند تغيير السائق أو السيارة)</span>
|
||||
<input
|
||||
style={inputBase}
|
||||
<Input
|
||||
label="سبب التعديل"
|
||||
hint="مطلوب عند تغيير السائق أو السيارة"
|
||||
{...register("reason")}
|
||||
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 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 type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</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" />}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={isSubmitting}>
|
||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { TRIP_STATUS_MAP } 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 ────────────────
|
||||
// (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"] }) {
|
||||
const s = TRIP_STATUS_MAP[status];
|
||||
return (
|
||||
@@ -30,6 +32,21 @@ function fmtDate(iso?: string | null): string {
|
||||
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
|
||||
* Displays a paginated, searchable list of archived trips.
|
||||
@@ -56,19 +73,14 @@ export function ArchivedTripList({ onView }: ArchivedTripListProps) {
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="relative w-full sm:w-72">
|
||||
<svg
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-text-hint)]"
|
||||
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"
|
||||
<div className="w-full sm:w-72">
|
||||
<Input
|
||||
label="بحث"
|
||||
placeholder="بحث برقم الرحلة أو العنوان..."
|
||||
value={search}
|
||||
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>
|
||||
@@ -142,22 +154,22 @@ export function ArchivedTripList({ onView }: ArchivedTripListProps) {
|
||||
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={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
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === pages}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Spinner ,Toast} from "../UI";
|
||||
import { Button, Toast } from "../UI";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import type { TripNotification } from "@/src/hooks/useTrip";
|
||||
@@ -78,30 +78,9 @@ export function TripReportPanel({ tripId }: TripReportPanelProps) {
|
||||
إنشاء تقرير الرحلة
|
||||
</p>
|
||||
|
||||
<button
|
||||
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" />}
|
||||
<Button type="button" onClick={handleGenerate} loading={loading}>
|
||||
{loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Toast
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Accessible labelled input with error and hint state.
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "@/src/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
@@ -29,7 +30,10 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
|
||||
* 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 errorId = `${inputId}-error`;
|
||||
const hintId = `${inputId}-hint`;
|
||||
@@ -54,6 +58,7 @@ export function Input({ label, error, hint, id, icon, className, ...rest }: Inpu
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={
|
||||
@@ -88,4 +93,4 @@ export function Input({ label, error, hint, id, icon, className, ...rest }: Inpu
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -24,3 +24,4 @@ export { EmptyState } from "./EmptyState";
|
||||
export { Badge } from "./Badge";
|
||||
|
||||
export { ArchiveButton } from "./ArchiveButton"
|
||||
export { FileInput } from "./FailInput"
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Alert, Button, Modal, Spinner } from "../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { userService } from "@/src/services/user.service";
|
||||
import type { UserDetail } from "@/src/types/user";
|
||||
@@ -12,6 +12,8 @@ interface UserDetailModalProps {
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
return (
|
||||
<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 }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -68,13 +73,6 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// fetch user details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -98,60 +96,18 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="detail-title"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 55,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={user?.name ?? "عرض المستخدم"}
|
||||
subtitle="بيانات المستخدم"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 480,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||
overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* ── header ── */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#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>
|
||||
|
||||
{/* ── body ── */}
|
||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
|
||||
|
||||
{/* loading */}
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||
@@ -161,17 +117,7 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
||||
)}
|
||||
|
||||
{/* error */}
|
||||
{!loading && error && (
|
||||
<div style={{
|
||||
padding: "1rem 1.25rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||
fontSize: 13, color: "#991B1B", fontWeight: 500,
|
||||
textAlign: "center",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && <Alert type="error" message={error} />}
|
||||
|
||||
{/* content */}
|
||||
{!loading && user && (
|
||||
@@ -211,31 +157,6 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── footer ── */}
|
||||
<div style={{
|
||||
padding: "1rem 1.5rem",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex", justifyContent: "flex-end",
|
||||
}}>
|
||||
<button
|
||||
type="button" onClick={onClose}
|
||||
style={{
|
||||
height: 40, padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { Resolver } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Button, Input, Select } from "../UI";
|
||||
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
import type { Role } from "@/src/types/role";
|
||||
import type { FormErrors, 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 {};
|
||||
}
|
||||
}
|
||||
import type { User, UserFormData } from "@/src/types/user";
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
interface UserFormModalProps {
|
||||
@@ -57,57 +22,44 @@ interface UserFormModalProps {
|
||||
export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }: UserFormModalProps) {
|
||||
const isNew = editUser === null;
|
||||
|
||||
const [form, setForm] = useState<UserFormData>({
|
||||
// Custom resolver wrapper — on edit, an empty password field must be
|
||||
// treated as "not provided" (skip the min(8) rule entirely) rather than
|
||||
// validated as an empty string, exactly like the old validate() call did
|
||||
// by stripping `password` from the object before running the schema.
|
||||
const resolver: Resolver<UserFormData> = async (values, context, options) => {
|
||||
const schema = isNew ? createUserSchema : updateUserSchema;
|
||||
const data = !isNew && !values.password ? { ...values, password: undefined } : values;
|
||||
return yupResolver<UserFormData>(schema as any)(data as UserFormData, context, options);
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
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 [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 };
|
||||
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);
|
||||
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" } : {}),
|
||||
});
|
||||
const ok = await onSubmit(payload as UserFormData, isNew);
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -145,75 +97,97 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
||||
{isNew ? "مستخدم جديد" : editUser?.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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
||||
<form onSubmit={handleSubmit(submitHandler)} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{errors.name?.type === "manual" && (
|
||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||
)}
|
||||
|
||||
{/* name */}
|
||||
<label style={S.label}>
|
||||
الاسم الكامل *
|
||||
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="أحمد الرشيدي" autoComplete="name" dir="rtl" />
|
||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
||||
</label>
|
||||
<Input
|
||||
label="الاسم الكامل *"
|
||||
{...register("name")}
|
||||
error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
|
||||
placeholder="أحمد الرشيدي"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
dir="rtl"
|
||||
/>
|
||||
|
||||
{/* email + phone */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
البريد الإلكتروني
|
||||
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="ahmed@co.sa" autoComplete="email" dir="ltr" />
|
||||
{errors.email && <span style={S.errorText}>{errors.email}</span>}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
رقم الهاتف *
|
||||
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" />
|
||||
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
|
||||
</label>
|
||||
<Input
|
||||
label="البريد الإلكتروني"
|
||||
type="email"
|
||||
{...register("email")}
|
||||
error={errors.email?.message}
|
||||
placeholder="ahmed@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>
|
||||
|
||||
{/* password */}
|
||||
<label style={S.label}>
|
||||
{isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"}
|
||||
<input style={inputStyle("password")} type="password" value={form.password} onChange={set("password")} placeholder="••••••••" autoComplete={isNew ? "new-password" : "off"} dir="ltr" />
|
||||
{errors.password && <span style={S.errorText}>{errors.password}</span>}
|
||||
</label>
|
||||
<Input
|
||||
label={isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"}
|
||||
type="password"
|
||||
{...register("password")}
|
||||
error={errors.password?.message}
|
||||
placeholder="••••••••"
|
||||
autoComplete={isNew ? "new-password" : "off"}
|
||||
dir="ltr"
|
||||
/>
|
||||
|
||||
{/* role + branch */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
الدور *
|
||||
<select style={{ ...inputStyle("roleId"), cursor: "pointer" }} value={form.roleId} onChange={set("roleId")} dir="rtl">
|
||||
<Select
|
||||
label="الدور *"
|
||||
{...register("roleId")}
|
||||
error={errors.roleId?.message}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر الدور</option>
|
||||
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
{errors.roleId && <span style={S.errorText}>{errors.roleId}</span>}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
الفرع *
|
||||
<select style={{ ...inputStyle("branchId"), cursor: "pointer" }} value={form.branchId} onChange={set("branchId")} dir="rtl">
|
||||
</Select>
|
||||
<Select
|
||||
label="الفرع *"
|
||||
{...register("branchId")}
|
||||
error={errors.branchId?.message}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر الفرع</option>
|
||||
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
{errors.branchId && <span style={S.errorText}>{errors.branchId}</span>}
|
||||
</label>
|
||||
</Select>
|
||||
</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 type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</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>
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={isSubmitting}>
|
||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة المستخدم" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../UI";
|
||||
import { Badge, Button, EmptyState, Spinner } from "../UI";
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||
@@ -24,6 +16,11 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
}
|
||||
|
||||
// ── icon button ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// (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 }) {
|
||||
return (
|
||||
<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>
|
||||
) : users.length === 0 ? (
|
||||
/* empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
|
||||
</p>
|
||||
{!search && (
|
||||
<button type="button" onClick={onAddFirst}
|
||||
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
أضف أول مستخدم
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<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>
|
||||
<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>
|
||||
<RoleBadge name={u.role?.name} />
|
||||
<Badge label={u.role?.name ?? "—"} color={u.role?.name === "مدير النظام" ? "indigo" : "slate"} />
|
||||
<StatusBadge active={u.isActive} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{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>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
||||
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "../../UI";
|
||||
import { Alert, Button, Modal, Spinner } from "../../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { archivedUserService } from "@/src/services/archive/archivedUser.service";
|
||||
import type { ArchivedUser } from "@/src/types/user";
|
||||
@@ -12,6 +12,8 @@ interface ArchivedUserDetailModalProps {
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
return (
|
||||
<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 }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -68,13 +73,6 @@ export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailM
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// fetch archived user details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -98,60 +96,18 @@ useEffect(() => {
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="archived-detail-title"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 55,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={user?.name ?? "عرض المستخدم"}
|
||||
subtitle="مستخدم مؤرشف"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 480,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||
overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* ── header ── */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
|
||||
مستخدم مؤرشف
|
||||
</p>
|
||||
<h2 id="archived-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>
|
||||
|
||||
{/* ── body ── */}
|
||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
|
||||
|
||||
{/* loading */}
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||
@@ -161,17 +117,7 @@ useEffect(() => {
|
||||
)}
|
||||
|
||||
{/* error */}
|
||||
{!loading && error && (
|
||||
<div style={{
|
||||
padding: "1rem 1.25rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||
fontSize: 13, color: "#991B1B", fontWeight: 500,
|
||||
textAlign: "center",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && <Alert type="error" message={error} />}
|
||||
|
||||
{/* content */}
|
||||
{!loading && user && (
|
||||
@@ -205,31 +151,6 @@ useEffect(() => {
|
||||
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── footer ── */}
|
||||
<div style={{
|
||||
padding: "1rem 1.5rem",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex", justifyContent: "flex-end",
|
||||
}}>
|
||||
<button
|
||||
type="button" onClick={onClose}
|
||||
style={{
|
||||
height: 40, padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedUserTable } from "./Archivedusertable";
|
||||
import { ArchivedUserDetailModal } from "./Archiveduserdetailmodal";
|
||||
import { useArchivedUsers } from "@/src/hooks/archive/useArchivedUsers";
|
||||
@@ -10,6 +10,21 @@ interface ArchivedUsersModalProps {
|
||||
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) {
|
||||
const [viewUserId, setViewUserId] = useState<string | null>(null);
|
||||
|
||||
@@ -20,79 +35,27 @@ export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
||||
} = useArchivedUsers();
|
||||
|
||||
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 && (
|
||||
<ArchivedUserDetailModal userId={viewUserId} onClose={() => setViewUserId(null)} />
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 920,
|
||||
background: "var(--color-surface-sunken)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
subtitle="الأرشيف"
|
||||
title={`المستخدمون المؤرشفون (${total})`}
|
||||
>
|
||||
{/* header */}
|
||||
<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-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"
|
||||
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
placeholder="بحث بالاسم أو الهاتف..."
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
icon={searchIcon}
|
||||
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>
|
||||
|
||||
@@ -108,7 +71,7 @@ export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Button, EmptyState, Spinner } from "../../UI";
|
||||
import type { ArchivedUser } from "@/src/types/user";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||
@@ -14,6 +16,11 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
}
|
||||
|
||||
// ── icon button ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// (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 }) {
|
||||
return (
|
||||
<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>
|
||||
) : users.length === 0 ? (
|
||||
/* empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
@@ -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>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
||||
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 {
|
||||
createCarSchema,
|
||||
@@ -18,35 +18,9 @@ import type {
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
import { branchService } from "@/src/services/branch.service";
|
||||
|
||||
// ── Shared input style ────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
// ── Shared styles ─────────────────────────────────────────────────────────────
|
||||
// Only section-heading styling is left here — every input/select/label/error
|
||||
// visual is now owned by the shared <Input /> / <Select /> components.
|
||||
|
||||
const sectionHeadingStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
@@ -57,6 +31,8 @@ const sectionHeadingStyle: React.CSSProperties = {
|
||||
margin: "0.5rem 0 0",
|
||||
};
|
||||
|
||||
const FORM_ID = "car-form";
|
||||
|
||||
// ── Date helper ───────────────────────────────────────────────────────────────
|
||||
// <input type="date"> gives "YYYY-MM-DD"; backend expects full ISO-8601.
|
||||
|
||||
@@ -140,9 +116,13 @@ export function CarFormModal({
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setFocus,
|
||||
formState: { errors, isSubmitting },
|
||||
} = 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: {
|
||||
manufacturer: editCar?.manufacturer ?? "",
|
||||
model: editCar?.model ?? "",
|
||||
@@ -169,18 +149,14 @@ export function CarFormModal({
|
||||
});
|
||||
|
||||
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(() => {
|
||||
firstRef.current?.focus();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
setFocus("manufacturer");
|
||||
}, [setFocus]);
|
||||
|
||||
// Numeric fields: empty string -> undefined (never NaN), matching the
|
||||
// behavior of the old parseNum() helper.
|
||||
@@ -191,11 +167,6 @@ export function CarFormModal({
|
||||
setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)),
|
||||
});
|
||||
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...inputBase,
|
||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
|
||||
const submitHandler = async (data: CarFormValues) => {
|
||||
@@ -244,109 +215,28 @@ export function CarFormModal({
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="car-modal-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? "مركبة جديدة" : `${editCar?.manufacturer} ${editCar?.model}`}
|
||||
subtitle={isNew ? "إضافة مركبة" : "تعديل مركبة"}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||
{isNew ? "إضافة المركبة" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 620,
|
||||
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",
|
||||
margin: "auto",
|
||||
}}
|
||||
>
|
||||
{/* 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,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إضافة مركبة" : "تعديل مركبة"}
|
||||
</p>
|
||||
<h2
|
||||
id="car-modal-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
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>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
maxHeight: "75vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||
dir="rtl"
|
||||
>
|
||||
{errors.manufacturer?.type === "manual" && (
|
||||
@@ -361,234 +251,137 @@ export function CarFormModal({
|
||||
<p style={sectionHeadingStyle}>بيانات أساسية</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={labelStyle}>
|
||||
الشركة المصنعة *
|
||||
<input
|
||||
ref={firstRef}
|
||||
style={withError(!!errors.manufacturer && errors.manufacturer.type !== "manual")}
|
||||
<Input
|
||||
label="الشركة المصنعة *"
|
||||
{...register("manufacturer")}
|
||||
error={
|
||||
errors.manufacturer && errors.manufacturer.type !== "manual"
|
||||
? errors.manufacturer.message
|
||||
: undefined
|
||||
}
|
||||
placeholder="تويوتا"
|
||||
dir="rtl"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.manufacturer && errors.manufacturer.type !== "manual" && (
|
||||
<span style={errorTextStyle}>{errors.manufacturer.message}</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الموديل *
|
||||
<input
|
||||
style={withError(!!errors.model)}
|
||||
<Input
|
||||
label="الموديل *"
|
||||
{...register("model")}
|
||||
error={errors.model?.message}
|
||||
placeholder="لاند كروزر"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.model && <span style={errorTextStyle}>{errors.model.message}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={labelStyle}>
|
||||
سنة الصنع *
|
||||
<input
|
||||
style={withError(!!errors.year)}
|
||||
<Input
|
||||
label="سنة الصنع *"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={new Date().getFullYear() + 1}
|
||||
{...numberField("year")}
|
||||
error={errors.year?.message}
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.year && <span style={errorTextStyle}>{errors.year.message}</span>}
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
اللون
|
||||
<input style={inputBase} {...register("color")} placeholder="أبيض" dir="rtl" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
نوع اللوحة
|
||||
<input style={inputBase} {...register("plateType")} placeholder="خاص" dir="rtl" />
|
||||
</label>
|
||||
<Input label="اللون" {...register("color")} placeholder="أبيض" dir="rtl" />
|
||||
<Input label="نوع اللوحة" {...register("plateType")} placeholder="خاص" dir="rtl" />
|
||||
</div>
|
||||
|
||||
{/* ── Section: Plate ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات اللوحة</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={labelStyle}>
|
||||
رقم اللوحة *
|
||||
<input
|
||||
style={withError(!!errors.plateNumber)}
|
||||
<Input
|
||||
label="رقم اللوحة *"
|
||||
{...register("plateNumber")}
|
||||
error={errors.plateNumber?.message}
|
||||
placeholder="1234"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.plateNumber && (
|
||||
<span style={errorTextStyle}>{errors.plateNumber.message}</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
حروف اللوحة *
|
||||
<input
|
||||
style={withError(!!errors.plateLetters)}
|
||||
<Input
|
||||
label="حروف اللوحة *"
|
||||
{...register("plateLetters")}
|
||||
error={errors.plateLetters?.message}
|
||||
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)}
|
||||
<Input
|
||||
label="رقم الاستمارة *"
|
||||
{...register("registrationNumber")}
|
||||
error={errors.registrationNumber?.message}
|
||||
placeholder="SA-001234"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.registrationNumber && (
|
||||
<span style={errorTextStyle}>{errors.registrationNumber.message}</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم الهيكل (VIN)
|
||||
<input
|
||||
style={withError(!!errors.vinNumber)}
|
||||
<Input
|
||||
label="رقم الهيكل (VIN)"
|
||||
{...register("vinNumber")}
|
||||
error={errors.vinNumber?.message}
|
||||
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">
|
||||
<Input
|
||||
label="انتهاء الاستمارة"
|
||||
type="date"
|
||||
{...register("registrationExpiryDate")}
|
||||
/>
|
||||
<Select label="حالة التأمين" {...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>
|
||||
</Select>
|
||||
<Input
|
||||
label="انتهاء التأمين"
|
||||
type="date"
|
||||
{...register("insuranceExpiryDate")}
|
||||
/>
|
||||
<Input
|
||||
label="انتهاء الفحص الدوري"
|
||||
type="date"
|
||||
{...register("inspectionExpiryDate")}
|
||||
/>
|
||||
</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">
|
||||
<Select label="الفرع" {...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">
|
||||
</Select>
|
||||
<Select label="الحالة" {...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)}
|
||||
</Select>
|
||||
<Input label="رقم GPS" {...register("gpsDeviceId")} placeholder="GPS-001" dir="ltr" />
|
||||
<Input
|
||||
label="الطاقة الاستيعابية"
|
||||
type="number"
|
||||
min={0}
|
||||
{...numberField("capacity")}
|
||||
error={errors.capacity?.message}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.capacity && (
|
||||
<span style={errorTextStyle}>{errors.capacity.message}</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الوزن (كجم)
|
||||
<input
|
||||
style={withError(!!errors.weight)}
|
||||
<Input
|
||||
label="الوزن (كجم)"
|
||||
type="number"
|
||||
min={0}
|
||||
{...numberField("weight")}
|
||||
error={errors.weight?.message}
|
||||
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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 type { Car, InsuranceStatus } from "@/src/types/car";
|
||||
|
||||
@@ -78,10 +78,15 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
||||
{car ? `${car.manufacturer} ${car.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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
@@ -138,10 +143,9 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
||||
|
||||
{/* footer */}
|
||||
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end" }}>
|
||||
<button type="button" onClick={onClose}
|
||||
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Button } from "../../UI";
|
||||
import { ArchivedCarTable } from "./Archivedcartable";
|
||||
import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel";
|
||||
import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars";
|
||||
@@ -69,36 +69,33 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
||||
</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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</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
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
type="text"
|
||||
placeholder="بحث بالماركة أو اللوحة..."
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
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)",
|
||||
}}
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Spinner, Button } from "../../UI";
|
||||
import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
|
||||
import type { Car } from "@/src/types/car";
|
||||
|
||||
@@ -19,10 +19,22 @@ const thStyle: React.CSSProperties = {
|
||||
// ── icon button ──────────────────────────────────────────────────────────────
|
||||
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
||||
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" }}>
|
||||
<Button
|
||||
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}
|
||||
</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.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
||||
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
|
||||
<Button
|
||||
key={btn.label}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={btn.action}
|
||||
disabled={btn.disabled}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Alert, Button, Modal, Select, Spinner } from "../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { roleService } from "@/src/services/role.service";
|
||||
import { Permission, Role } from "@/src/types/role";
|
||||
@@ -14,6 +14,7 @@ interface RoleDetailModalProps {
|
||||
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 }) {
|
||||
return (
|
||||
<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 }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -54,12 +57,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
const [pendingPermId, setPendingPermId] = useState("");
|
||||
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 () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
@@ -115,44 +112,18 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="role-detail-title"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 55,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={role?.name ?? "عرض الدور"}
|
||||
subtitle="تفاصيل الدور"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
width: "100%", maxWidth: 520,
|
||||
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",
|
||||
}}>
|
||||
<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="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 style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
@@ -160,11 +131,7 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div style={{ padding: "1rem 1.25rem", borderRadius: "var(--radius-lg)", background: "#FEF2F2", border: "1px solid #FECACA", fontSize: 13, color: "#991B1B", fontWeight: 500, textAlign: "center" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && <Alert type="error" message={error} />}
|
||||
|
||||
{!loading && role && (
|
||||
<div dir="rtl">
|
||||
@@ -197,38 +164,29 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
</p>
|
||||
|
||||
{assignablePermissions.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<select
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12, alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<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)",
|
||||
}}
|
||||
dir="rtl"
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleAssign}
|
||||
disabled={!pendingPermId}
|
||||
loading={mutating}
|
||||
>
|
||||
{mutating && <Spinner size="sm" className="text-white" />}
|
||||
إضافة
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -262,18 +220,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { createRoleSchema, updateRoleSchema, type RoleFormErrors } from "@/src/validations/role.validator";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Button, Input, Modal, Textarea } from "../UI";
|
||||
import { createRoleSchema, updateRoleSchema } from "@/src/validations/role.validator";
|
||||
import { Permission, Role, RoleFormData } from "@/src/types/role";
|
||||
|
||||
// ── 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,
|
||||
};
|
||||
|
||||
// ── 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 {};
|
||||
}
|
||||
}
|
||||
const FORM_ID = "role-form";
|
||||
|
||||
// ── Permission checkbox group ─────────────────────────────────────────────────
|
||||
function PermissionGroup({
|
||||
@@ -100,146 +66,111 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
||||
|
||||
const currentPermIds = editRole?.permissions?.map(p => p.permission.id) ?? [];
|
||||
|
||||
const [form, setForm] = useState<RoleFormData>({
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
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(); }, []);
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
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 submitHandler = async (data: RoleFormData) => {
|
||||
const ok = await onSubmit(data, isNew);
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="role-modal-title"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? "دور جديد" : editRole?.name ?? ""}
|
||||
subtitle={isNew ? "إضافة دور" : "تعديل دور"}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||
{isNew ? "إنشاء الدور" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 600,
|
||||
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",
|
||||
maxHeight: "90vh",
|
||||
}}
|
||||
>
|
||||
{/* 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)",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||
{isNew ? "إضافة دور" : "تعديل دور"}
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
onSubmit={handleSubmit} noValidate
|
||||
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto", flex: 1 }}
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{ 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 */}
|
||||
<label style={S.label} dir="rtl">
|
||||
اسم الدور *
|
||||
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="مثال: مدير الفروع" dir="rtl" />
|
||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
||||
</label>
|
||||
|
||||
{/* Description */}
|
||||
<label style={S.label} dir="rtl">
|
||||
الوصف
|
||||
<textarea
|
||||
style={{
|
||||
...S.input, height: "auto", padding: "0.5rem 0.75rem", resize: "none",
|
||||
...(errors.description ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
} as React.CSSProperties}
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={set("description")}
|
||||
placeholder="وصف مختصر لمهام هذا الدور…"
|
||||
<div dir="rtl">
|
||||
<Input
|
||||
label="اسم الدور *"
|
||||
{...register("name")}
|
||||
error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
|
||||
placeholder="مثال: مدير الفروع"
|
||||
dir="rtl"
|
||||
autoFocus
|
||||
/>
|
||||
{errors.description && <span style={S.errorText}>{errors.description}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div dir="rtl">
|
||||
<Textarea
|
||||
label="الوصف"
|
||||
{...register("description")}
|
||||
placeholder="وصف مختصر لمهام هذا الدور…"
|
||||
error={errors.description?.message}
|
||||
dir="rtl"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permissions — bound via Controller since it's a custom checkbox
|
||||
group, not a plain input/select/textarea. */}
|
||||
{permissions.length > 0 && (
|
||||
<Controller
|
||||
name="permissionIds"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div 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)" }}>
|
||||
الصلاحيات
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setValue("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>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setValue("permissionIds", [])}
|
||||
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}
|
||||
>
|
||||
إلغاء الكل
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -249,31 +180,28 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
||||
}}>
|
||||
{Object.entries(grouped).map(([module, perms]) => (
|
||||
<PermissionGroup
|
||||
key={module} module={module} perms={perms}
|
||||
selected={form.permissionIds} onToggle={togglePerm}
|
||||
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 }}>
|
||||
{form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
|
||||
{field.value.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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Role } from "@/src/types/role";
|
||||
import { Spinner } from "../UI";
|
||||
|
||||
import { Button, EmptyState, Spinner } from "../UI";
|
||||
|
||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -23,6 +24,11 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
}
|
||||
|
||||
// ── Icon button ───────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// (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 }: {
|
||||
onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode;
|
||||
}) {
|
||||
@@ -90,21 +96,18 @@ export function RoleTable({
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<div style={{ fontSize: 40, marginBottom: 12 }}>🛡️</div>
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: "0 0 8px" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"}
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: "0 0 16px" }}>
|
||||
{!search && "ابدأ بإنشاء أول دور في النظام"}
|
||||
</p>
|
||||
{!search && (
|
||||
<button type="button" onClick={onAddFirst}
|
||||
style={{ fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
||||
<EmptyState
|
||||
icon="🛡️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"}
|
||||
description={!search ? "ابدأ بإنشاء أول دور في النظام" : undefined}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
إضافة أول دور
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* Data rows */
|
||||
<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>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
||||
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1 }}>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "../../UI";
|
||||
import { Alert, Button, Modal, Spinner } from "../../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { archivedRoleService } from "@/src/services/archive/archivedRole.service";
|
||||
import type { ArchivedRole } from "@/src/types/role";
|
||||
@@ -12,6 +12,8 @@ interface ArchivedRoleDetailModalProps {
|
||||
}
|
||||
|
||||
// ── small helper components ───────────────────────────────────────────────────
|
||||
// (No equivalents in the shared UI kit — purpose-built layouts, not generic
|
||||
// form/action controls, so they stay custom.)
|
||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -29,6 +31,8 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -69,13 +73,6 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// fetch archived role details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -99,60 +96,18 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="archived-role-detail-title"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 55,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={role?.name ?? "عرض الدور"}
|
||||
subtitle="دور مؤرشف"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 480,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||
overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* ── header ── */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
|
||||
دور مؤرشف
|
||||
</p>
|
||||
<h2 id="archived-role-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{role?.name ?? "عرض الدور"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button" onClick={onClose} aria-label="إغلاق"
|
||||
style={{
|
||||
width: 34, height: 34, borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
cursor: "pointer", fontSize: 18,
|
||||
color: "var(--color-text-muted)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── body ── */}
|
||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
|
||||
|
||||
{/* loading */}
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||
@@ -162,17 +117,7 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
||||
)}
|
||||
|
||||
{/* error — fallback UI on API failure */}
|
||||
{!loading && error && (
|
||||
<div style={{
|
||||
padding: "1rem 1.25rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||
fontSize: 13, color: "#991B1B", fontWeight: 500,
|
||||
textAlign: "center",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && <Alert type="error" message={error} />}
|
||||
|
||||
{/* content */}
|
||||
{!loading && role && (
|
||||
@@ -203,31 +148,6 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
||||
<DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── footer ── */}
|
||||
<div style={{
|
||||
padding: "1rem 1.5rem",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex", justifyContent: "flex-end",
|
||||
}}>
|
||||
<button
|
||||
type="button" onClick={onClose}
|
||||
style={{
|
||||
height: 40, padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Button, EmptyState, Spinner } from "../../UI";
|
||||
import type { ArchivedRole } from "@/src/types/role";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||
@@ -14,6 +16,9 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
}
|
||||
|
||||
// ── icon button ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// isn't built for a fixed 32×32 square icon chip.
|
||||
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
||||
@@ -68,11 +73,10 @@ export function ArchivedRoleTable({ roles, loading, search, page, pages, onView,
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
/* empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="🛡️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
@@ -114,15 +118,24 @@ export function ArchivedRoleTable({ roles, loading, search, page, pages, onView,
|
||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
||||
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedRoleTable } from "./ArchivedRoleTable";
|
||||
import { ArchivedRoleDetailModal } from "./ArchivedRoleDetailModal";
|
||||
import { useArchivedRoles } from "@/src/hooks/archive/useArchiveRole";
|
||||
@@ -10,6 +10,21 @@ interface ArchivedRolesModalProps {
|
||||
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) {
|
||||
const [viewRoleId, setViewRoleId] = useState<string | null>(null);
|
||||
|
||||
@@ -20,79 +35,28 @@ export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
|
||||
} = useArchivedRoles();
|
||||
|
||||
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 && (
|
||||
<ArchivedRoleDetailModal roleId={viewRoleId} onClose={() => setViewRoleId(null)} />
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 920,
|
||||
background: "var(--color-surface-sunken)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
subtitle="الأرشيف"
|
||||
title={`الأدوار المؤرشفة (${total})`}
|
||||
>
|
||||
{/* header */}
|
||||
<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" }}>
|
||||
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{/* search — client-side, since /role/archived has no search param */}
|
||||
<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"
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
placeholder="بحث باسم الدور..."
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
icon={searchIcon}
|
||||
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>
|
||||
|
||||
@@ -109,7 +73,7 @@ export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user