create driver page and client page also create oll driver , client layer
This commit is contained in:
392
Components/Client/Addressformmodal.tsx
Normal file
392
Components/Client/Addressformmodal.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Alert, Spinner } from "../UI";
|
||||||
|
import type {
|
||||||
|
ClientAddress,
|
||||||
|
ClientAddressFormData,
|
||||||
|
ClientAddressFormErrors,
|
||||||
|
} from "../../types/client";
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Validation ─────────────────────────────────────────────────────────────
|
||||||
|
function validate(data: ClientAddressFormData): ClientAddressFormErrors {
|
||||||
|
const e: ClientAddressFormErrors = {};
|
||||||
|
if (!data.label.trim()) e.label = "النوع مطلوب";
|
||||||
|
if (!data.street.trim()) e.street = "العنوان مطلوب";
|
||||||
|
if (!data.city.trim()) e.city = "المدينة مطلوبة";
|
||||||
|
if (!data.state.trim()) e.state = "المنطقة مطلوبة";
|
||||||
|
if (!data.postalCode.trim()) e.postalCode = "الرمز البريدي مطلوب";
|
||||||
|
if (!data.country.trim()) e.country = "الدولة مطلوبة";
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_PRESETS = ["فوترة", "شحن", "المقر الرئيسي", "فرع", "مستودع", "أخرى"];
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
interface AddressFormModalProps {
|
||||||
|
editAddress: ClientAddress | null; // null = create mode
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: ClientAddressFormData, isNew: boolean) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
export function AddressFormModal({
|
||||||
|
editAddress,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: AddressFormModalProps) {
|
||||||
|
const isNew = editAddress === null;
|
||||||
|
|
||||||
|
const [form, setForm] = useState<ClientAddressFormData>({
|
||||||
|
label: editAddress?.label ?? "",
|
||||||
|
street: editAddress?.street ?? "",
|
||||||
|
city: editAddress?.city ?? "",
|
||||||
|
state: editAddress?.state ?? "",
|
||||||
|
postalCode: editAddress?.postalCode ?? "",
|
||||||
|
country: editAddress?.country ?? "المملكة العربية السعودية",
|
||||||
|
isPrimary: editAddress?.isPrimary ?? false,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<ClientAddressFormErrors>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [apiError, setApiError] = useState("");
|
||||||
|
const firstRef = useRef<HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => { firstRef.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 ClientAddressFormData) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||||
|
if (errors[field as keyof ClientAddressFormErrors])
|
||||||
|
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errs = validate(form);
|
||||||
|
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 ClientAddressFormErrors): React.CSSProperties => ({
|
||||||
|
...S.input,
|
||||||
|
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
style={{
|
||||||
|
padding: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{apiError && (
|
||||||
|
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label + isPrimary */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto",
|
||||||
|
gap: "0.75rem",
|
||||||
|
alignItems: "end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={S.label}>
|
||||||
|
نوع العنوان *
|
||||||
|
<select
|
||||||
|
ref={firstRef}
|
||||||
|
style={{ ...inputStyle("label"), cursor: "pointer" }}
|
||||||
|
value={form.label}
|
||||||
|
onChange={set("label")}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر النوع</option>
|
||||||
|
{LABEL_PRESETS.map((l) => (
|
||||||
|
<option key={l} value={l}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.label && <span style={S.errorText}>{errors.label}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
paddingBottom: 2,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isPrimary}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((p) => ({ ...p, isPrimary: e.target.checked }))
|
||||||
|
}
|
||||||
|
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
عنوان رئيسي
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Street */}
|
||||||
|
<label style={S.label}>
|
||||||
|
الشارع / العنوان التفصيلي *
|
||||||
|
<input
|
||||||
|
style={inputStyle("street")}
|
||||||
|
value={form.street}
|
||||||
|
onChange={set("street")}
|
||||||
|
placeholder="شارع الملك فهد، مبنى 12"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.street && <span style={S.errorText}>{errors.street}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* City + State */}
|
||||||
|
<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("state")}
|
||||||
|
value={form.state}
|
||||||
|
onChange={set("state")}
|
||||||
|
placeholder="منطقة الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.state && <span style={S.errorText}>{errors.state}</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Postal code + Country */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={S.label}>
|
||||||
|
الرمز البريدي *
|
||||||
|
<input
|
||||||
|
style={inputStyle("postalCode")}
|
||||||
|
value={form.postalCode}
|
||||||
|
onChange={set("postalCode")}
|
||||||
|
placeholder="11564"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.postalCode && (
|
||||||
|
<span style={S.errorText}>{errors.postalCode}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label style={S.label}>
|
||||||
|
الدولة *
|
||||||
|
<input
|
||||||
|
style={inputStyle("country")}
|
||||||
|
value={form.country}
|
||||||
|
onChange={set("country")}
|
||||||
|
placeholder="المملكة العربية السعودية"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.country && <span style={S.errorText}>{errors.country}</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.25rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
cursor: saving ? "not-allowed" : "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.5rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: saving
|
||||||
|
? "var(--color-brand-400)"
|
||||||
|
: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: saving ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving && <Spinner size="sm" className="text-white" />}
|
||||||
|
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
Components/Client/ClientAddressForm.tsx
Normal file
175
Components/Client/ClientAddressForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useState, FormEvent } from "react";
|
||||||
|
import { ClientAddress, CreateClientAddressPayload } from "../../types/client";
|
||||||
|
import { Input, Select, Button } from "../UI";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ClientAddressFormProps {
|
||||||
|
clientId: string;
|
||||||
|
initialValues?: Partial<ClientAddress>;
|
||||||
|
onSubmit: (data: CreateClientAddressPayload) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
submitLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormErrors = Partial<Record<keyof CreateClientAddressPayload, string>>;
|
||||||
|
|
||||||
|
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validate(v: CreateClientAddressPayload): FormErrors {
|
||||||
|
const e: FormErrors = {};
|
||||||
|
if (!v.label.trim()) e.label = "Label is required.";
|
||||||
|
if (!v.street.trim()) e.street = "Street is required.";
|
||||||
|
if (!v.city.trim()) e.city = "City is required.";
|
||||||
|
if (!v.state.trim()) e.state = "State / Province is required.";
|
||||||
|
if (!v.postalCode.trim()) e.postalCode = "Postal code is required.";
|
||||||
|
if (!v.country.trim()) e.country = "Country is required.";
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common address labels – quick-pick for the user
|
||||||
|
const LABEL_PRESETS = ["Billing", "Shipping", "Head Office", "Branch", "Warehouse", "Other"];
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ClientAddressForm({
|
||||||
|
clientId,
|
||||||
|
initialValues = {},
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitLabel = "Save address",
|
||||||
|
}: ClientAddressFormProps) {
|
||||||
|
const [values, setValues] = useState<CreateClientAddressPayload>({
|
||||||
|
clientId,
|
||||||
|
label: initialValues.label ?? "",
|
||||||
|
street: initialValues.street ?? "",
|
||||||
|
city: initialValues.city ?? "",
|
||||||
|
state: initialValues.state ?? "",
|
||||||
|
postalCode: initialValues.postalCode ?? "",
|
||||||
|
country: initialValues.country ?? "",
|
||||||
|
isPrimary: initialValues.isPrimary ?? false,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const set =
|
||||||
|
(field: keyof Omit<CreateClientAddressPayload, "clientId" | "isPrimary">) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
setValues((v) => ({ ...v, [field]: e.target.value }));
|
||||||
|
if (errors[field]) setErrors((er) => ({ ...er, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errs = validate(values);
|
||||||
|
if (Object.keys(errs).length) {
|
||||||
|
setErrors(errs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(values);
|
||||||
|
} catch {
|
||||||
|
/* parent surfaces the error */
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||||
|
{/* Label + Primary toggle */}
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<Select
|
||||||
|
label="Label"
|
||||||
|
value={values.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValues((v) => ({ ...v, label: e.target.value }));
|
||||||
|
if (errors.label) setErrors((er) => ({ ...er, label: undefined }));
|
||||||
|
}}
|
||||||
|
error={errors.label}
|
||||||
|
wrapperClassName="flex-1"
|
||||||
|
>
|
||||||
|
<option value="">Select a label…</option>
|
||||||
|
{LABEL_PRESETS.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<label className="mb-1 flex items-center gap-2 text-sm text-slate-600 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={values.isPrimary}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => ({ ...v, isPrimary: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
Primary address
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Street */}
|
||||||
|
<Input
|
||||||
|
label="Street address"
|
||||||
|
placeholder="123 Main St, Suite 4"
|
||||||
|
value={values.street}
|
||||||
|
onChange={set("street")}
|
||||||
|
error={errors.street}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* City + State */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="City"
|
||||||
|
placeholder="New York"
|
||||||
|
value={values.city}
|
||||||
|
onChange={set("city")}
|
||||||
|
error={errors.city}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="State / Province"
|
||||||
|
placeholder="NY"
|
||||||
|
value={values.state}
|
||||||
|
onChange={set("state")}
|
||||||
|
error={errors.state}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Postal code + Country */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Postal code"
|
||||||
|
placeholder="10001"
|
||||||
|
value={values.postalCode}
|
||||||
|
onChange={set("postalCode")}
|
||||||
|
error={errors.postalCode}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Country"
|
||||||
|
placeholder="United States"
|
||||||
|
value={values.country}
|
||||||
|
onChange={set("country")}
|
||||||
|
error={errors.country}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
Components/Client/ClientForm.tsx
Normal file
135
Components/Client/ClientForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState, FormEvent } from "react";
|
||||||
|
import { Client, CreateClientPayload } from "../../types/client";
|
||||||
|
import { Input, Textarea, Button } from "../UI";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ClientFormProps {
|
||||||
|
/** Pre-populated when editing; omit for create */
|
||||||
|
initialValues?: Partial<Client>;
|
||||||
|
onSubmit: (data: CreateClientPayload) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
submitLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormErrors = Partial<Record<keyof CreateClientPayload, string>>;
|
||||||
|
|
||||||
|
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validate(values: CreateClientPayload): FormErrors {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
if (!values.name.trim()) errors.name = "Name is required.";
|
||||||
|
if (!values.email.trim()) {
|
||||||
|
errors.email = "Email is required.";
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
|
||||||
|
errors.email = "Enter a valid email address.";
|
||||||
|
}
|
||||||
|
if (!values.phone.trim()) errors.phone = "Phone is required.";
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ClientForm({
|
||||||
|
initialValues = {},
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitLabel = "Save client",
|
||||||
|
}: ClientFormProps) {
|
||||||
|
const [values, setValues] = useState<CreateClientPayload>({
|
||||||
|
name: initialValues.name ?? "",
|
||||||
|
email: initialValues.email ?? "",
|
||||||
|
phone: initialValues.phone ?? "",
|
||||||
|
taxId: initialValues.taxId ?? "",
|
||||||
|
notes: initialValues.notes ?? "",
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Generic field updater
|
||||||
|
const set = (field: keyof CreateClientPayload) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setValues((v) => ({ ...v, [field]: e.target.value }));
|
||||||
|
if (errors[field]) setErrors((er) => ({ ...er, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errs = validate(values);
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
setErrors(errs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(values);
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via parent hook's alert; keep form open
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||||
|
{/* Row 1: Name + Email */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Full name"
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
value={values.name}
|
||||||
|
onChange={set("name")}
|
||||||
|
error={errors.name}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="billing@acme.com"
|
||||||
|
value={values.email}
|
||||||
|
onChange={set("email")}
|
||||||
|
error={errors.email}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Phone + Tax ID */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="+1 555 000 0000"
|
||||||
|
value={values.phone}
|
||||||
|
onChange={set("phone")}
|
||||||
|
error={errors.phone}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Tax ID / VAT (optional)"
|
||||||
|
placeholder="US123456789"
|
||||||
|
value={values.taxId}
|
||||||
|
onChange={set("taxId")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Textarea
|
||||||
|
label="Notes (optional)"
|
||||||
|
placeholder="Anything worth remembering about this client…"
|
||||||
|
value={values.notes}
|
||||||
|
onChange={set("notes")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
Components/Client/Clientformmodal.tsx
Normal file
353
Components/Client/Clientformmodal.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Alert, Spinner } from "../UI";
|
||||||
|
import type { Client, ClientFormData, ClientFormErrors } from "../../types/client";
|
||||||
|
|
||||||
|
// ── Fixed styles (same token system as UserFormModal) ──────────────────────
|
||||||
|
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 ─────────────────────────────────────────────────────────────
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const PHONE_RE = /^\+?[0-9]{10,15}$/;
|
||||||
|
|
||||||
|
function validate(data: ClientFormData): ClientFormErrors {
|
||||||
|
const e: ClientFormErrors = {};
|
||||||
|
if (!data.name.trim()) e.name = "اسم العميل مطلوب";
|
||||||
|
if (!data.email.trim()) {
|
||||||
|
e.email = "البريد الإلكتروني مطلوب";
|
||||||
|
} else if (!EMAIL_RE.test(data.email)) {
|
||||||
|
e.email = "صيغة البريد الإلكتروني غير صحيحة";
|
||||||
|
}
|
||||||
|
if (!data.phone.trim()) {
|
||||||
|
e.phone = "رقم الهاتف مطلوب";
|
||||||
|
} else if (!PHONE_RE.test(data.phone.replace(/\s/g, ""))) {
|
||||||
|
e.phone = "رقم هاتف غير صالح (10–15 رقم)";
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
interface ClientFormModalProps {
|
||||||
|
editClient: Client | null; // null = create mode, Client = edit mode
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
export function ClientFormModal({
|
||||||
|
editClient,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: ClientFormModalProps) {
|
||||||
|
const isNew = editClient === null;
|
||||||
|
|
||||||
|
const [form, setForm] = useState<ClientFormData>({
|
||||||
|
name: editClient?.name ?? "",
|
||||||
|
email: editClient?.email ?? "",
|
||||||
|
phone: editClient?.phone ?? "",
|
||||||
|
taxId: editClient?.taxId ?? "",
|
||||||
|
notes: editClient?.notes ?? "",
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<ClientFormErrors>({});
|
||||||
|
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 ClientFormData) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||||
|
if (errors[field]) setErrors((p) => ({ ...p, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errs = validate(form);
|
||||||
|
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 ClientFormErrors): React.CSSProperties => ({
|
||||||
|
...S.input,
|
||||||
|
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
style={{
|
||||||
|
padding: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{apiError && (
|
||||||
|
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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="info@company.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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tax ID */}
|
||||||
|
<label style={S.label}>
|
||||||
|
الرقم الضريبي (اختياري)
|
||||||
|
<input
|
||||||
|
style={S.input}
|
||||||
|
value={form.taxId}
|
||||||
|
onChange={set("taxId")}
|
||||||
|
placeholder="300xxxxxxxxx"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<label style={S.label}>
|
||||||
|
ملاحظات (اختياري)
|
||||||
|
<textarea
|
||||||
|
style={{
|
||||||
|
...S.input,
|
||||||
|
height: 80,
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
resize: "vertical",
|
||||||
|
}}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={set("notes")}
|
||||||
|
placeholder="أي معلومات إضافية عن العميل…"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
403
Components/Client/Clienttable.tsx
Normal file
403
Components/Client/Clienttable.tsx
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Client } from "../../types/client";
|
||||||
|
|
||||||
|
// ── Address count badge ────────────────────────────────────────────────────
|
||||||
|
function AddressBadge({ count }: { count: number }) {
|
||||||
|
const hasAddr = count > 0;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: hasAddr ? "1px solid #BFDBFE" : "1px solid var(--color-border)",
|
||||||
|
background: hasAddr ? "#EFF6FF" : "var(--color-surface-muted)",
|
||||||
|
padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: hasAddr ? "#1D4ED8" : "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count} {count === 1 ? "عنوان" : "عناوين"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status badge ───────────────────────────────────────────────────────────
|
||||||
|
function StatusBadge({ active }: { active?: boolean }) {
|
||||||
|
const isActive = active !== false;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: isActive ? "1px solid #BBF7D0" : "1px solid #FECACA",
|
||||||
|
background: isActive ? "#DCFCE7" : "#FEF2F2",
|
||||||
|
padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isActive ? "#166534" : "#991B1B",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: isActive ? "#16A34A" : "#DC2626",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isActive ? "نشط" : "غير نشط"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Icon button (mirrors UserTable's IconBtn) ──────────────────────────────
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared card & header styles ────────────────────────────────────────────
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const thStyle: React.CSSProperties = {
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.2em",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
interface ClientTableProps {
|
||||||
|
clients: Client[];
|
||||||
|
loading: boolean;
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
onEdit: (client: Client) => void;
|
||||||
|
onDelete: (client: Client) => void;
|
||||||
|
onAddFirst: () => void;
|
||||||
|
onPageChange: (p: number) => void;
|
||||||
|
/** Navigate to address management for a client */
|
||||||
|
onManageAddresses: (client: Client) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main table ─────────────────────────────────────────────────────────────
|
||||||
|
export function ClientTable({
|
||||||
|
clients,
|
||||||
|
loading,
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
pages,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onAddFirst,
|
||||||
|
onPageChange,
|
||||||
|
onManageAddresses,
|
||||||
|
}: ClientTableProps) {
|
||||||
|
return (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
{/* Column headers */}
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||||
|
...thStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>العميل</span>
|
||||||
|
<span>البريد الإلكتروني</span>
|
||||||
|
<span>الهاتف</span>
|
||||||
|
<span>العناوين</span>
|
||||||
|
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
|
||||||
|
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "4rem 0",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
|
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||||
|
</div>
|
||||||
|
) : clients.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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
أضف أول عميل
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Data rows */
|
||||||
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
|
{clients.map((c, i) => (
|
||||||
|
<li
|
||||||
|
key={c.id}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.875rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background:
|
||||||
|
i % 2 !== 0
|
||||||
|
? "var(--color-surface-muted)"
|
||||||
|
: "transparent",
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => onManageAddresses(c)}
|
||||||
|
title={`إدارة عناوين ${c.name}`}
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</p>
|
||||||
|
{c.taxId && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.taxId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.email}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||||
|
{c.phone}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Address count */}
|
||||||
|
<AddressBadge count={c.addresses?.length ?? 0} />
|
||||||
|
|
||||||
|
{/* Created at */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()} // prevent row navigation
|
||||||
|
>
|
||||||
|
<IconBtn
|
||||||
|
title={`تعديل ${c.name}`}
|
||||||
|
color="#1D4ED8"
|
||||||
|
bg="#EFF6FF"
|
||||||
|
borderColor="#BFDBFE"
|
||||||
|
onClick={() => onEdit(c)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn
|
||||||
|
title={`حذف ${c.name}`}
|
||||||
|
color="#DC2626"
|
||||||
|
bg="#FEF2F2"
|
||||||
|
borderColor="#FECACA"
|
||||||
|
onClick={() => onDelete(c)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</IconBtn>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pages > 1 && (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
padding: "0.875rem 1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
صفحة{" "}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
Components/Client/Deleteconfirmmodal.tsx
Normal file
171
Components/Client/Deleteconfirmmodal.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Client } from "../../types/client";
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
client: Client;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmModal({
|
||||||
|
client,
|
||||||
|
deleting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteConfirmModalProps) {
|
||||||
|
// Close on Escape — identical to User/DeleteConfirmModal
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="del-client-title"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onCancel();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* أيقونة الحذف */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
margin: "0 auto",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#DC2626"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
id="del-client-title"
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
حذف العميل
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
هل أنت متأكد من حذف{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{client.name}
|
||||||
|
</strong>
|
||||||
|
؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: "#DC2626",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
opacity: deleting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
Components/Client/Toast.tsx
Normal file
64
Components/Client/Toast.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Notification } from "../../hooks/useClients";
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
notification: Notification | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toast({ notification }: ToastProps) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (notification) {
|
||||||
|
setVisible(true);
|
||||||
|
} else {
|
||||||
|
const t = setTimeout(() => setVisible(false), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [notification]);
|
||||||
|
|
||||||
|
if (!visible && !notification) return null;
|
||||||
|
|
||||||
|
const isSuccess = notification?.type === "success";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
left: "50%",
|
||||||
|
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
|
||||||
|
zIndex: 9999,
|
||||||
|
transition: "transform 250ms ease, opacity 250ms ease",
|
||||||
|
opacity: notification ? 1 : 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "0.75rem 1.25rem",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
background: isSuccess ? "#065F46" : "#7F1D1D",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
|
||||||
|
maxWidth: "90vw",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
|
||||||
|
<span>{notification?.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, type FC, type FormEvent } from "react";
|
|
||||||
import { saveSession, type ClientSession } from "@/lib/session";
|
|
||||||
import { clientService } from "@/services/client.service";
|
|
||||||
import { Spinner } from "../../Components/UI";
|
|
||||||
import { Alert } from "../../Components/UI";
|
|
||||||
import { Button } from "../../Components/UI";
|
|
||||||
import { Input } from "../../Components/UI";
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────
|
|
||||||
interface RegForm {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
companyName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RegErrors {
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── RegisterStep ────────────────────────────
|
|
||||||
interface RegisterStepProps {
|
|
||||||
onSuccess: (s: ClientSession) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RegisterStep: FC<RegisterStepProps> = ({ onSuccess }) => {
|
|
||||||
const [form, setForm] = useState<RegForm>({ name: "", email: "", phone: "", companyName: "" });
|
|
||||||
const [errors, setErrors] = useState<RegErrors>({});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [apiError, setApiError] = useState("");
|
|
||||||
|
|
||||||
const validate = (): boolean => {
|
|
||||||
const e: RegErrors = {};
|
|
||||||
if (!form.name.trim())
|
|
||||||
e.name = "الاسم الكامل مطلوب";
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
|
|
||||||
e.email = "أدخل بريدًا إلكترونيًا صحيحًا";
|
|
||||||
if (!/^(\+966|0)?[5][0-9]{8}$/.test(form.phone.replace(/\s/g, "")))
|
|
||||||
e.phone = "أدخل رقم هاتف سعودي صحيح (+966 5x xxx xxxx)";
|
|
||||||
setErrors(e);
|
|
||||||
return Object.keys(e).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!validate()) return;
|
|
||||||
setLoading(true);
|
|
||||||
setApiError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await clientService.create({
|
|
||||||
name: form.name.trim(),
|
|
||||||
email: form.email.trim(),
|
|
||||||
phone: form.phone.trim(),
|
|
||||||
companyName: form.companyName.trim() || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const session: ClientSession = {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
email: data.email,
|
|
||||||
phone: data.phone,
|
|
||||||
};
|
|
||||||
saveSession(session);
|
|
||||||
onSuccess(session);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setApiError(err instanceof Error ? err.message : "فشل التسجيل، يرجى المحاولة مجددًا.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const set = (field: keyof RegForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setForm(p => ({ ...p, [field]: e.target.value }));
|
|
||||||
if (errors[field as keyof RegErrors]) setErrors(p => ({ ...p, [field]: undefined }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div dir="rtl">
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="inline-flex items-center gap-1.5 bg-[#EBF3FF] border border-[#BFDBFE] rounded-full px-3 py-1 text-[11px] font-bold text-[#1E3A8A] mb-3">
|
|
||||||
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-[#1A73E8]" />
|
|
||||||
عميل جديد
|
|
||||||
</div>
|
|
||||||
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">إنشاء حساب جديد</h2>
|
|
||||||
<p className="text-[13px] text-[#6B7280] mt-1">أدخل بياناتك للبدء مع Slash.so.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{apiError && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<Alert message={apiError} onClose={() => setApiError("")} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
|
||||||
<Input
|
|
||||||
label="الاسم الكامل *"
|
|
||||||
placeholder="أحمد الرشيدي"
|
|
||||||
value={form.name}
|
|
||||||
onChange={set("name")}
|
|
||||||
error={errors.name}
|
|
||||||
autoComplete="name"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="البريد الإلكتروني *"
|
|
||||||
type="email"
|
|
||||||
placeholder="ahmed@company.sa"
|
|
||||||
value={form.email}
|
|
||||||
onChange={set("email")}
|
|
||||||
error={errors.email}
|
|
||||||
autoComplete="email"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="رقم الجوال *"
|
|
||||||
type="tel"
|
|
||||||
placeholder="+966 50 123 4567"
|
|
||||||
value={form.phone}
|
|
||||||
onChange={set("phone")}
|
|
||||||
error={errors.phone}
|
|
||||||
autoComplete="tel"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="اسم الشركة (اختياري)"
|
|
||||||
placeholder="شركة لوجي فلو للتوصيل"
|
|
||||||
value={form.companyName}
|
|
||||||
onChange={set("companyName")}
|
|
||||||
autoComplete="organization"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button type="submit" loading={loading} fullWidth>
|
|
||||||
إنشاء الحساب
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RegisterStep;
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import React, { useState, useEffect, type FC } from "react";
|
|
||||||
import { type ClientSession } from "@/lib/session";
|
|
||||||
import { clientService } from "@/services/client.service";
|
|
||||||
import type { ClientAddress } from "@/types/client";
|
|
||||||
import { Spinner } from "../../Components/UI";
|
|
||||||
import { Alert } from "../../Components/UI";
|
|
||||||
import { Button } from "../../Components/UI";
|
|
||||||
import { Input } from "../../Components/UI";
|
|
||||||
|
|
||||||
interface AddrForm {
|
|
||||||
label: string;
|
|
||||||
branchName: string;
|
|
||||||
contactName: string;
|
|
||||||
contactPhone: string;
|
|
||||||
city: string;
|
|
||||||
district: string;
|
|
||||||
street: string;
|
|
||||||
buildingNo: string;
|
|
||||||
unitNo: string;
|
|
||||||
zipCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddrErrors {
|
|
||||||
city?: string;
|
|
||||||
street?: string;
|
|
||||||
zipCode?: string;
|
|
||||||
contactPhone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LABEL_OPTIONS = ["عام", "المنزل", "المكتب الرئيسي", "المستودع", "الفرع"];
|
|
||||||
|
|
||||||
interface AddressStepProps {
|
|
||||||
session: ClientSession;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddressStep: FC<AddressStepProps> = ({ session, onSuccess }) => {
|
|
||||||
const [savedAddresses, setSavedAddresses] = useState<ClientAddress[]>([]);
|
|
||||||
const [loadingAddrs, setLoadingAddrs] = useState(true);
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [showNew, setShowNew] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [apiError, setApiError] = useState("");
|
|
||||||
const [formErrors, setFormErrors] = useState<AddrErrors>({});
|
|
||||||
const [form, setForm] = useState<AddrForm>({
|
|
||||||
label: "عام", branchName: "", contactName: "", contactPhone: "",
|
|
||||||
city: "", district: "", street: "", buildingNo: "", unitNo: "", zipCode: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
clientService.getAddresses(session.id)
|
|
||||||
.then(list => {
|
|
||||||
setSavedAddresses(list);
|
|
||||||
if (list.length > 0) setSelectedId(list[0]._id);
|
|
||||||
})
|
|
||||||
.catch(() => setShowNew(true))
|
|
||||||
.finally(() => setLoadingAddrs(false));
|
|
||||||
}, [session.id]);
|
|
||||||
|
|
||||||
const setF =
|
|
||||||
(field: keyof AddrForm) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
||||||
setForm(p => ({ ...p, [field]: e.target.value }));
|
|
||||||
if (formErrors[field as keyof AddrErrors])
|
|
||||||
setFormErrors(p => ({ ...p, [field]: undefined }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateAddress = (): boolean => {
|
|
||||||
const e: AddrErrors = {};
|
|
||||||
if (!form.city.trim()) e.city = "المدينة مطلوبة";
|
|
||||||
if (!form.street.trim()) e.street = "الشارع مطلوب";
|
|
||||||
if (form.zipCode && !/^\d{5}$/.test(form.zipCode))
|
|
||||||
e.zipCode = "الرمز البريدي يجب أن يتكون من 5 أرقام";
|
|
||||||
if (form.contactPhone && !/^(\+966|0)?[5][0-9]{8}$/.test(form.contactPhone.replace(/\s/g, "")))
|
|
||||||
e.contactPhone = "أدخل رقم جوال سعودي صحيح";
|
|
||||||
setFormErrors(e);
|
|
||||||
return Object.keys(e).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const canProceed = selectedId !== null || showNew;
|
|
||||||
|
|
||||||
const handleNext = async () => {
|
|
||||||
if (selectedId) {
|
|
||||||
setLoading(true);
|
|
||||||
await new Promise(r => setTimeout(r, 400));
|
|
||||||
setLoading(false);
|
|
||||||
onSuccess();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (showNew && !validateAddress()) return;
|
|
||||||
setLoading(true);
|
|
||||||
setApiError("");
|
|
||||||
try {
|
|
||||||
await clientService.addAddress(session.id, {
|
|
||||||
label: form.label,
|
|
||||||
branchName: form.branchName || null,
|
|
||||||
contactPerson: { name: form.contactName, phone: form.contactPhone },
|
|
||||||
details: {
|
|
||||||
city: form.city.trim(),
|
|
||||||
district: form.district.trim() || undefined,
|
|
||||||
street: form.street.trim(),
|
|
||||||
buildingNo: form.buildingNo.trim() || undefined,
|
|
||||||
unitNo: form.unitNo.trim() || undefined,
|
|
||||||
zipCode: form.zipCode.trim() || undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
onSuccess();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setApiError(err instanceof Error ? err.message : "تعذّر حفظ العنوان، يرجى المحاولة مجددًا.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div dir="rtl">
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="inline-flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1 text-[11px] font-bold text-[#065F46] mb-3">
|
|
||||||
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
||||||
<polyline points="20 6 9 17 4 12"/>
|
|
||||||
</svg>
|
|
||||||
تم إنشاء الحساب
|
|
||||||
</div>
|
|
||||||
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight leading-tight">اختر عنوان التوصيل</h2>
|
|
||||||
<p className="text-[13px] text-[#6B7280] mt-1">
|
|
||||||
العنوان <strong className="text-[#111827]">مطلوب</strong> قبل تقديم أي طلب.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{apiError && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<Alert message={apiError} onClose={() => setApiError("")} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loadingAddrs ? (
|
|
||||||
<div className="flex items-center justify-center py-8 gap-2">
|
|
||||||
<Spinner size="sm" />
|
|
||||||
<span className="text-[13px] text-[#6B7280]">جارٍ تحميل العناوين…</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Saved addresses */}
|
|
||||||
{savedAddresses.length > 0 && (
|
|
||||||
<div className="mb-5">
|
|
||||||
<p className="text-[11px] font-bold text-[#9CA3AF] uppercase tracking-wide mb-2">العناوين المحفوظة</p>
|
|
||||||
<div className="flex flex-col gap-2" role="radiogroup" aria-label="العناوين المحفوظة">
|
|
||||||
{savedAddresses.map(addr => (
|
|
||||||
<button
|
|
||||||
key={addr._id}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={selectedId === addr._id}
|
|
||||||
onClick={() => { setSelectedId(addr._id); setShowNew(false); }}
|
|
||||||
className={`w-full text-right border rounded-xl p-3.5 transition-all duration-150 ${
|
|
||||||
selectedId === addr._id
|
|
||||||
? "border-[#1A73E8] bg-[#EBF3FF] ring-2 ring-[#1A73E8]/15"
|
|
||||||
: "border-[#E5E7EB] bg-white hover:border-[#1A73E8]/40"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`w-5 h-5 rounded-full border-2 flex-shrink-0 mt-0.5 flex items-center justify-center transition-all ${
|
|
||||||
selectedId === addr._id ? "border-[#1A73E8] bg-[#1A73E8]" : "border-[#D1D5DB]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{selectedId === addr._id && (
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3.5">
|
|
||||||
<polyline points="20 6 9 17 4 12"/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
|
|
||||||
addr.label === "المكتب الرئيسي"
|
|
||||||
? "bg-[#EBF3FF] text-[#1E3A8A]"
|
|
||||||
: "bg-[#FEF3C7] text-[#92400E]"
|
|
||||||
}`}>
|
|
||||||
{addr.label}
|
|
||||||
</span>
|
|
||||||
{addr.branchName && <span className="text-[12px] text-[#6B7280]">{addr.branchName}</span>}
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] font-semibold text-[#111827]">
|
|
||||||
{addr.details.street}
|
|
||||||
{addr.details.buildingNo && `، مبنى ${addr.details.buildingNo}`}
|
|
||||||
، {addr.details.city}
|
|
||||||
</p>
|
|
||||||
{addr.details.district && (
|
|
||||||
<p className="text-[12px] text-[#6B7280]">
|
|
||||||
{addr.details.district}
|
|
||||||
{addr.details.zipCode && ` · ${addr.details.zipCode}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-[11px] text-[#9CA3AF] mt-1">
|
|
||||||
{addr.contactPerson.name} · {addr.contactPerson.phone}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New address toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setShowNew(v => !v); setSelectedId(null); }}
|
|
||||||
aria-expanded={showNew}
|
|
||||||
className={`w-full border-2 border-dashed rounded-xl p-3 flex items-center justify-center gap-2 text-[13px] font-semibold transition-all ${
|
|
||||||
showNew
|
|
||||||
? "border-[#1A73E8] text-[#1A73E8] bg-[#EBF3FF]"
|
|
||||||
: "border-[#E5E7EB] text-[#6B7280] hover:border-[#1A73E8]/50 hover:text-[#1A73E8]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
إضافة عنوان جديد
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* New address form */}
|
|
||||||
{showNew && (
|
|
||||||
<div className="mt-4 border border-[#E5E7EB] rounded-xl p-4 bg-[#FAFAFA] flex flex-col gap-3">
|
|
||||||
<p className="text-[11px] font-bold text-[#9CA3AF] uppercase tracking-wide">تفاصيل العنوان الجديد</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="addr-label" className="text-[12px] font-semibold text-[#374151]">التصنيف</label>
|
|
||||||
<select
|
|
||||||
id="addr-label"
|
|
||||||
value={form.label}
|
|
||||||
onChange={setF("label")}
|
|
||||||
className="h-10 px-3 border border-[#E5E7EB] rounded-[var(--radius-md)] text-[13px] bg-white outline-none focus:border-[#1A73E8] focus:ring-2 focus:ring-[#1A73E8]/15 text-right"
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
{LABEL_OPTIONS.map(l => <option key={l}>{l}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Input label="اسم الفرع" placeholder="مقر الرياض" value={form.branchName} onChange={setF("branchName")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Input label="اسم جهة الاتصال" placeholder="أحمد الرشيدي" value={form.contactName} onChange={setF("contactName")} />
|
|
||||||
<Input label="رقم جوال جهة الاتصال" placeholder="+966 50 123 4567" value={form.contactPhone} onChange={setF("contactPhone")} error={formErrors.contactPhone} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="border-[#E5E7EB]" />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Input label="المدينة *" placeholder="الرياض" value={form.city} onChange={setF("city")} error={formErrors.city} />
|
|
||||||
<Input label="الحي" placeholder="العليا" value={form.district} onChange={setF("district")} />
|
|
||||||
</div>
|
|
||||||
<Input label="الشارع *" placeholder="طريق الملك فهد" value={form.street} onChange={setF("street")} error={formErrors.street} />
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<Input label="رقم المبنى" placeholder="12" value={form.buildingNo} onChange={setF("buildingNo")} />
|
|
||||||
<Input label="رقم الوحدة" placeholder="3ب" value={form.unitNo} onChange={setF("unitNo")} />
|
|
||||||
<Input label="الرمز البريدي" placeholder="12241" value={form.zipCode} onChange={setF("zipCode")} error={formErrors.zipCode} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!canProceed && !loadingAddrs && (
|
|
||||||
<p role="alert" className="text-[12px] text-[#F59E0B] font-medium mt-3 flex items-center gap-1.5">
|
|
||||||
يرجى اختيار عنوان أو إضافة عنوان جديد للمتابعة.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<Button
|
|
||||||
onClick={handleNext}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!canProceed || loadingAddrs}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
التالي — عرض الطلبات
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddressStep;
|
|
||||||
100
Components/Driver/DriverDeleteModal.tsx
Normal file
100
Components/Driver/DriverDeleteModal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Driver } from "../../types/driver";
|
||||||
|
|
||||||
|
interface DriverDeleteModalProps {
|
||||||
|
driver: Driver;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverDeleteModal({ driver, deleting, onCancel, onConfirm }: DriverDeleteModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="driver-del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%", maxWidth: 420,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, margin: "0 auto", borderRadius: "50%",
|
||||||
|
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 id="driver-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
حذف السائق
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{driver.name}</strong>
|
||||||
|
{" "}(
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||||
|
{driver.phone}
|
||||||
|
</span>
|
||||||
|
)؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
||||||
|
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)",
|
||||||
|
}}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||||
|
border: "none", background: "#DC2626",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: deleting ? "not-allowed" : "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1,
|
||||||
|
}}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
770
Components/Driver/DriverDetailPanel.tsx
Normal file
770
Components/Driver/DriverDetailPanel.tsx
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import { driverService } from "../../services/driver.service";
|
||||||
|
import { getStoredToken } from "../../lib/auth";
|
||||||
|
import type { Driver } from "../../types/driver";
|
||||||
|
import {
|
||||||
|
DRIVER_STATUS_MAP,
|
||||||
|
DRIVER_CARD_TYPE_MAP,
|
||||||
|
NATIONAL_ID_TYPE_MAP,
|
||||||
|
} from "../../types/driver";
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Format an ISO date string to a readable Arabic date. */
|
||||||
|
function fmtDate(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the date is within 90 days in the future (or already past). */
|
||||||
|
function isExpiringSoon(iso?: string | null): boolean {
|
||||||
|
if (!iso) return false;
|
||||||
|
const diff = new Date(iso).getTime() - Date.now();
|
||||||
|
return diff <= 90 * 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the full image URL via the proxy to avoid CORS issues. */
|
||||||
|
function buildPhotoUrl(filename?: string | null): string | null {
|
||||||
|
if (!filename) return null;
|
||||||
|
return `/api/proxy/driver-photos/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono = false,
|
||||||
|
warn = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
warn?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
padding: "0.55rem 0",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||||
|
color: warn ? "#D97706" : "var(--color-text-primary)",
|
||||||
|
fontWeight: warn ? 600 : 400,
|
||||||
|
maxWidth: "60%",
|
||||||
|
textAlign: "left",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{warn && value !== "—" ? "⚠ " : ""}
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeading({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: "1.25rem 0 0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Report generator sub-panel ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ReportPanel({ driverId }: { driverId: string }) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const [date, setDate] = useState(today);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reportUrl, setReportUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setReportUrl(null);
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await driverService.getDailyReport(driverId, date, token);
|
||||||
|
const url = (
|
||||||
|
res as unknown as { data: { reportUrl: string } }
|
||||||
|
).data?.reportUrl;
|
||||||
|
setReportUrl(url ?? null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "تعذّر إنشاء التقرير.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: "0 0 0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إنشاء تقرير يومي
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
{/* Date input */}
|
||||||
|
<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"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={today}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDate(e.target.value);
|
||||||
|
setReportUrl(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 38,
|
||||||
|
padding: "0 0.625rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading || !date}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
height: 38,
|
||||||
|
padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background:
|
||||||
|
loading || !date
|
||||||
|
? "var(--color-brand-400)"
|
||||||
|
: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: loading || !date ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && <Spinner size="sm" className="text-white" />}
|
||||||
|
{loading ? "جارٍ الإنشاء…" : "إنشاء التقرير"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success link */}
|
||||||
|
{reportUrl && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
background: "#DCFCE7",
|
||||||
|
border: "1px solid #BBF7D0",
|
||||||
|
padding: "0.625rem 0.875rem",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#166534",
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>✓ تم إنشاء التقرير بنجاح</span>
|
||||||
|
<a
|
||||||
|
href={reportUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
color: "#166534",
|
||||||
|
textDecoration: "underline",
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
فتح التقرير ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DriverDetailPanelProps {
|
||||||
|
driverId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: (driver: Driver) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DriverDetailPanel({
|
||||||
|
driverId,
|
||||||
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
}: DriverDetailPanelProps) {
|
||||||
|
const [driver, setDriver] = useState<Driver | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
|
// Fetch driver data
|
||||||
|
const loadDriver = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await driverService.getById(driverId, token);
|
||||||
|
setDriver((res as unknown as { data: Driver }).data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [driverId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDriver();
|
||||||
|
}, [loadDriver]);
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const photoUrl = driver ? buildPhotoUrl(driver.photo) : null;
|
||||||
|
const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 40,
|
||||||
|
background: "rgba(15,23,42,0.45)",
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Slide-in panel — from the left, mirroring CarDetailPanel */}
|
||||||
|
<aside
|
||||||
|
aria-label="تفاصيل السائق"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
width: "min(520px, 100vw)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
boxShadow: "8px 0 40px rgba(0,0,0,.18)",
|
||||||
|
overflowY: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1.25rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Photo + name */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: "50%",
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0,
|
||||||
|
border: "2px solid var(--color-brand-200)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{photoUrl && !imgError ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt={driver?.name ?? "صورة السائق"}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* Fallback: initials avatar */
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{driver?.name?.charAt(0) ?? "?"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ملف السائق
|
||||||
|
</p>
|
||||||
|
{driver && (
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: "3px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{driver.name}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{driver?.userName && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
margin: "2px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{driver.userName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<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",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Scrollable content ── */}
|
||||||
|
<div
|
||||||
|
style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}
|
||||||
|
>
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "2rem 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
|
جارٍ التحميل…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#DC2626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{driver && !loading && (
|
||||||
|
<>
|
||||||
|
{/* Status badges */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusConfig && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: `1px solid ${statusConfig.border}`,
|
||||||
|
background: statusConfig.bg,
|
||||||
|
padding: "0.3rem 0.875rem",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: statusConfig.color,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: statusConfig.dot,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!driver.isActive && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
padding: "0.3rem 0.875rem",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#DC2626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
محذوف
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: Personal Info ── */}
|
||||||
|
<SectionHeading title="البيانات الشخصية" />
|
||||||
|
<DetailRow label="الاسم الكامل" value={driver.name} />
|
||||||
|
<DetailRow label="رقم الجوال" value={driver.phone} mono />
|
||||||
|
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
|
||||||
|
<DetailRow label="العنوان" value={driver.address ?? "—"} />
|
||||||
|
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
|
||||||
|
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
|
||||||
|
|
||||||
|
{/* ── Section: ID & GOSI ── */}
|
||||||
|
<SectionHeading title="الهوية والتأمينات" />
|
||||||
|
<DetailRow
|
||||||
|
label="نوع الهوية"
|
||||||
|
value={
|
||||||
|
driver.nationalIdType
|
||||||
|
? NATIONAL_ID_TYPE_MAP[driver.nationalIdType]
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow label="رقم الهوية" value={driver.nationalId ?? "—"} mono />
|
||||||
|
<DetailRow
|
||||||
|
label="انتهاء الهوية"
|
||||||
|
value={fmtDate(driver.nationalIdExpiry)}
|
||||||
|
warn={isExpiringSoon(driver.nationalIdExpiry)}
|
||||||
|
/>
|
||||||
|
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
|
||||||
|
|
||||||
|
{/* ── Section: License ── */}
|
||||||
|
<SectionHeading title="بيانات رخصة القيادة" />
|
||||||
|
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono />
|
||||||
|
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
|
||||||
|
<DetailRow
|
||||||
|
label="انتهاء الرخصة"
|
||||||
|
value={fmtDate(driver.licenseExpiry)}
|
||||||
|
warn={isExpiringSoon(driver.licenseExpiry)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Section: Driver Card ── */}
|
||||||
|
<SectionHeading title="بطاقة السائق" />
|
||||||
|
<DetailRow
|
||||||
|
label="رقم البطاقة"
|
||||||
|
value={driver.driverCardNumber ?? "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="نوع البطاقة"
|
||||||
|
value={
|
||||||
|
driver.driverCardType
|
||||||
|
? DRIVER_CARD_TYPE_MAP[driver.driverCardType]
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="انتهاء البطاقة"
|
||||||
|
value={fmtDate(driver.driverCardExpiry)}
|
||||||
|
warn={isExpiringSoon(driver.driverCardExpiry)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="نوع السائق"
|
||||||
|
value={driver.driverType ?? "—"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Section: System Info ── */}
|
||||||
|
<SectionHeading title="معلومات النظام" />
|
||||||
|
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono />
|
||||||
|
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} />
|
||||||
|
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
|
||||||
|
|
||||||
|
{/* ── Status History ── */}
|
||||||
|
{driver.statusHistory && driver.statusHistory.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHeading title="سجل الحالات" />
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{driver.statusHistory.slice(0, 5).map((h) => {
|
||||||
|
const s =
|
||||||
|
DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={h.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: `1px solid ${s.border}`,
|
||||||
|
background: s.bg,
|
||||||
|
padding: "0.5rem 0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: s.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
{h.reason && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
— {h.reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtDate(h.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Report Panel ── */}
|
||||||
|
<ReportPanel driverId={driver.id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer actions ── */}
|
||||||
|
{driver && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem 1.5rem",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(driver)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#DC2626",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
603
Components/Driver/DriverFormModal.tsx
Normal file
603
Components/Driver/DriverFormModal.tsx
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Alert, Spinner } from "../UI";
|
||||||
|
import { get } from "../../services/api";
|
||||||
|
import { getStoredToken } from "../../lib/auth";
|
||||||
|
import type {
|
||||||
|
Driver,
|
||||||
|
DriverFormErrors,
|
||||||
|
CreateDriverPayload,
|
||||||
|
UpdateDriverPayload,
|
||||||
|
NationalIdType,
|
||||||
|
DriverCardType,
|
||||||
|
DriverStatus,
|
||||||
|
} from "../../types/driver";
|
||||||
|
import type { Branch } from "../../types/branch";
|
||||||
|
|
||||||
|
// ── 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 sectionHeadingStyle: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: "0.5rem 0 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Date helper ──────────────────────────────────────────────────────────────
|
||||||
|
function toIsoDateTime(val: string): string {
|
||||||
|
if (!val) return val;
|
||||||
|
if (val.includes("T")) return val;
|
||||||
|
return `${val}T00:00:00.000Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
function validate(form: Partial<CreateDriverPayload>): DriverFormErrors {
|
||||||
|
const e: DriverFormErrors = {};
|
||||||
|
if (!form.name?.trim()) e.name = "الاسم مطلوب";
|
||||||
|
if (!form.phone?.trim()) e.phone = "رقم الجوال مطلوب";
|
||||||
|
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
|
||||||
|
e.email = "البريد الإلكتروني غير صالح";
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
interface DriverFormModalProps {
|
||||||
|
editDriver: Driver | null;
|
||||||
|
branches: Branch[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (
|
||||||
|
payload: CreateDriverPayload | UpdateDriverPayload,
|
||||||
|
isNew: boolean,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export function DriverFormModal({
|
||||||
|
editDriver,
|
||||||
|
branches: branchesProp,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: DriverFormModalProps) {
|
||||||
|
const isNew = editDriver === null;
|
||||||
|
|
||||||
|
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||||
|
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||||
|
useEffect(() => {
|
||||||
|
if (branchesProp.length > 0) {
|
||||||
|
setBranches(branchesProp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = getStoredToken();
|
||||||
|
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
|
||||||
|
.then((res) => {
|
||||||
|
const list =
|
||||||
|
(res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
|
||||||
|
setBranches(list);
|
||||||
|
})
|
||||||
|
.catch(() => { /* silently ignore */ });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Form state ────────────────────────────────────────────────────────────
|
||||||
|
const [name, setName] = useState(editDriver?.name ?? "");
|
||||||
|
const [phone, setPhone] = useState(editDriver?.phone ?? "");
|
||||||
|
const [email, setEmail] = useState(editDriver?.email ?? "");
|
||||||
|
const [address, setAddress] = useState(editDriver?.address ?? "");
|
||||||
|
const [nationality, setNationality] = useState(editDriver?.nationality ?? "");
|
||||||
|
const [nationalIdType, setNationalIdType] = useState<NationalIdType | "">(
|
||||||
|
editDriver?.nationalIdType ?? "",
|
||||||
|
);
|
||||||
|
const [nationalId, setNationalId] = useState(
|
||||||
|
// nationalId is not in CreateDriverPayload — read-only from Driver
|
||||||
|
(editDriver as Driver & { nationalId?: string })?.nationalId ?? "",
|
||||||
|
);
|
||||||
|
const [nationalIdExpiry, setNationalIdExpiry] = useState(
|
||||||
|
(editDriver as Driver & { nationalIdExpiry?: string })?.nationalIdExpiry?.slice(0, 10) ?? "",
|
||||||
|
);
|
||||||
|
const [gosiNumber, setGosiNumber] = useState(editDriver?.gosiNumber ?? "");
|
||||||
|
const [licenseNumber, setLicenseNumber] = useState(editDriver?.licenseNumber ?? "");
|
||||||
|
const [licenseType, setLicenseType] = useState(editDriver?.licenseType ?? "");
|
||||||
|
const [licenseExpiry, setLicenseExpiry] = useState(
|
||||||
|
editDriver?.licenseExpiry?.slice(0, 10) ?? "",
|
||||||
|
);
|
||||||
|
const [driverCardNumber, setDriverCardNumber] = useState(editDriver?.driverCardNumber ?? "");
|
||||||
|
const [driverCardType, setDriverCardType] = useState<DriverCardType | "">(
|
||||||
|
editDriver?.driverCardType ?? "",
|
||||||
|
);
|
||||||
|
const [driverCardExpiry, setDriverCardExpiry] = useState(
|
||||||
|
editDriver?.driverCardExpiry?.slice(0, 10) ?? "",
|
||||||
|
);
|
||||||
|
const [driverType, setDriverType] = useState(editDriver?.driverType ?? "");
|
||||||
|
const [branchId, setBranchId] = useState(
|
||||||
|
(editDriver as Driver & { branchId?: string })?.branchId ?? "",
|
||||||
|
);
|
||||||
|
const [status, setStatus] = useState<DriverStatus>(editDriver?.status ?? "Active");
|
||||||
|
|
||||||
|
// Photo state (new uploads only)
|
||||||
|
const [photo, setPhoto] = useState<File | null>(null);
|
||||||
|
const [nationalPhoto, setNationalPhoto] = useState<File | null>(null);
|
||||||
|
const [driverCardPhoto, setDriverCardPhoto] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<DriverFormErrors>({});
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// ── Input error style ──────────────────────────────────────────────────────
|
||||||
|
const inputStyle = (field: keyof DriverFormErrors): React.CSSProperties => ({
|
||||||
|
...inputBase,
|
||||||
|
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFieldError = (field: keyof DriverFormErrors) =>
|
||||||
|
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||||
|
|
||||||
|
// ── Submit ─────────────────────────────────────────────────────────────────
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const errs = validate({ name, phone, email: email || undefined });
|
||||||
|
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||||
|
|
||||||
|
const raw: Record<string, unknown> = { name, phone };
|
||||||
|
if (email) raw.email = email;
|
||||||
|
if (address) raw.address = address;
|
||||||
|
if (nationality) raw.nationality = nationality;
|
||||||
|
if (nationalIdType) raw.nationalIdType = nationalIdType;
|
||||||
|
if (gosiNumber) raw.gosiNumber = gosiNumber;
|
||||||
|
if (licenseNumber) raw.licenseNumber = licenseNumber;
|
||||||
|
if (licenseType) raw.licenseType = licenseType;
|
||||||
|
if (licenseExpiry) raw.licenseExpiry = toIsoDateTime(licenseExpiry);
|
||||||
|
if (driverCardNumber) raw.driverCardNumber = driverCardNumber;
|
||||||
|
if (driverCardType) raw.driverCardType = driverCardType;
|
||||||
|
if (driverCardExpiry) raw.driverCardExpiry = toIsoDateTime(driverCardExpiry);
|
||||||
|
if (driverType) raw.driverType = driverType;
|
||||||
|
if (branchId) raw.branchId = branchId;
|
||||||
|
if (!isNew) raw.status = status;
|
||||||
|
if (photo) raw.photo = photo;
|
||||||
|
if (nationalPhoto) raw.nationalPhoto = nationalPhoto;
|
||||||
|
if (driverCardPhoto) raw.driverCardPhoto = driverCardPhoto;
|
||||||
|
|
||||||
|
const payload = raw as unknown as CreateDriverPayload;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setApiError("");
|
||||||
|
const ok = await onSubmit(payload, isNew);
|
||||||
|
setSaving(false);
|
||||||
|
if (ok) onClose();
|
||||||
|
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── File input helper ──────────────────────────────────────────────────────
|
||||||
|
function FileInput({
|
||||||
|
label: lbl,
|
||||||
|
current,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
current: File | null;
|
||||||
|
onChange: (f: File | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{lbl}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
|
||||||
|
style={{
|
||||||
|
...inputBase,
|
||||||
|
padding: "0.35rem 0.75rem",
|
||||||
|
height: "auto",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{current && (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{current.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
style={{
|
||||||
|
padding: "1.5rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "1rem",
|
||||||
|
maxHeight: "75vh", overflowY: "auto",
|
||||||
|
}}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{apiError && (
|
||||||
|
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Section: Personal Info ── */}
|
||||||
|
<p style={sectionHeadingStyle}>البيانات الشخصية</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الاسم الكامل *
|
||||||
|
<input
|
||||||
|
ref={firstRef}
|
||||||
|
style={inputStyle("name")}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => { setName(e.target.value); clearFieldError("name"); }}
|
||||||
|
placeholder="محمد عبدالله"
|
||||||
|
dir="rtl"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{errors.name && <span style={errorTextStyle}>{errors.name}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم الجوال *
|
||||||
|
<input
|
||||||
|
style={inputStyle("phone")}
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => { setPhone(e.target.value); clearFieldError("phone"); }}
|
||||||
|
placeholder="05XXXXXXXX"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.phone && <span style={errorTextStyle}>{errors.phone}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
البريد الإلكتروني
|
||||||
|
<input
|
||||||
|
style={inputStyle("email")}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); clearFieldError("email"); }}
|
||||||
|
placeholder="example@mail.com"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.email && <span style={errorTextStyle}>{errors.email}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الجنسية
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={nationality}
|
||||||
|
onChange={(e) => setNationality(e.target.value)}
|
||||||
|
placeholder="سعودي"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
|
||||||
|
العنوان
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="الرياض، حي..."
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: ID & GOSI ── */}
|
||||||
|
<p style={sectionHeadingStyle}>الهوية والتأمينات</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
نوع الهوية
|
||||||
|
<select
|
||||||
|
style={{ ...inputBase, cursor: "pointer" }}
|
||||||
|
value={nationalIdType}
|
||||||
|
onChange={(e) => setNationalIdType(e.target.value as NationalIdType | "")}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر النوع</option>
|
||||||
|
<option value="NationalID">هوية وطنية</option>
|
||||||
|
<option value="Iqama">إقامة</option>
|
||||||
|
<option value="Passport">جواز سفر</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم الهوية
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={nationalId}
|
||||||
|
onChange={(e) => setNationalId(e.target.value)}
|
||||||
|
placeholder="1XXXXXXXXX"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
انتهاء الهوية
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
type="date"
|
||||||
|
value={nationalIdExpiry}
|
||||||
|
onChange={(e) => setNationalIdExpiry(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم GOSI
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={gosiNumber}
|
||||||
|
onChange={(e) => setGosiNumber(e.target.value)}
|
||||||
|
placeholder="GOSI-XXXX"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: License ── */}
|
||||||
|
<p style={sectionHeadingStyle}>رخصة القيادة</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم الرخصة
|
||||||
|
<input
|
||||||
|
style={inputStyle("licenseNumber")}
|
||||||
|
value={licenseNumber}
|
||||||
|
onChange={(e) => { setLicenseNumber(e.target.value); clearFieldError("licenseNumber"); }}
|
||||||
|
placeholder="LIC-XXXX"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.licenseNumber && <span style={errorTextStyle}>{errors.licenseNumber}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
نوع الرخصة
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={licenseType}
|
||||||
|
onChange={(e) => setLicenseType(e.target.value)}
|
||||||
|
placeholder="خاص / عام"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
انتهاء الرخصة
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
type="date"
|
||||||
|
value={licenseExpiry}
|
||||||
|
onChange={(e) => setLicenseExpiry(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: Driver Card ── */}
|
||||||
|
<p style={sectionHeadingStyle}>بطاقة السائق</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم البطاقة
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={driverCardNumber}
|
||||||
|
onChange={(e) => setDriverCardNumber(e.target.value)}
|
||||||
|
placeholder="CARD-XXXX"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
نوع البطاقة
|
||||||
|
<select
|
||||||
|
style={{ ...inputBase, cursor: "pointer" }}
|
||||||
|
value={driverCardType}
|
||||||
|
onChange={(e) => setDriverCardType(e.target.value as DriverCardType | "")}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر النوع</option>
|
||||||
|
<option value="Temporary">مؤقتة</option>
|
||||||
|
<option value="Seasonal">موسمية</option>
|
||||||
|
<option value="Annual">سنوية</option>
|
||||||
|
<option value="Restricted">مقيدة</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
انتهاء البطاقة
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
type="date"
|
||||||
|
value={driverCardExpiry}
|
||||||
|
onChange={(e) => setDriverCardExpiry(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: Operational ── */}
|
||||||
|
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الفرع
|
||||||
|
<select
|
||||||
|
style={{ ...inputBase, cursor: "pointer" }}
|
||||||
|
value={branchId}
|
||||||
|
onChange={(e) => setBranchId(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر الفرع</option>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>{b.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
نوع السائق
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={driverType}
|
||||||
|
onChange={(e) => setDriverType(e.target.value)}
|
||||||
|
placeholder="رئيسي / احتياطي"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الحالة
|
||||||
|
<select
|
||||||
|
style={{ ...inputBase, cursor: "pointer" }}
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value as DriverStatus)}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="Active">نشط</option>
|
||||||
|
<option value="InTrip">في رحلة</option>
|
||||||
|
<option value="Inactive">غير نشط</option>
|
||||||
|
<option value="Suspended">موقوف</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: Photos ── */}
|
||||||
|
<p style={sectionHeadingStyle}>الصور والمستندات</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<FileInput label="صورة السائق" current={photo} onChange={setPhoto} />
|
||||||
|
<FileInput label="صورة الهوية" current={nationalPhoto} onChange={setNationalPhoto} />
|
||||||
|
<FileInput label="صورة البطاقة" current={driverCardPhoto} onChange={setDriverCardPhoto} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Actions ── */}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 1.25rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
||||||
|
cursor: saving ? "not-allowed" : "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 1.5rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: saving ? "not-allowed" : "pointer",
|
||||||
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving && <Spinner size="sm" className="text-white" />}
|
||||||
|
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة السائق" : "حفظ التغييرات"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
Components/UI/Badge.tsx
Normal file
22
Components/UI/Badge.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
interface BadgeProps {
|
||||||
|
label: string;
|
||||||
|
color?: "indigo" | "green" | "amber" | "red" | "slate";
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE_COLORS: Record<string, string> = {
|
||||||
|
indigo: "bg-indigo-100 text-indigo-700",
|
||||||
|
green: "bg-emerald-100 text-emerald-700",
|
||||||
|
amber: "bg-amber-100 text-amber-700",
|
||||||
|
red: "bg-red-100 text-red-700",
|
||||||
|
slate: "bg-slate-100 text-slate-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({ label, color = "slate" }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold ${BADGE_COLORS[color]}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
Components/UI/ConfirmDialog.tsx
Normal file
43
Components/UI/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Button } from "./Button";
|
||||||
|
import { Modal } from "./ModalProps";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = "Delete",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
loading = false,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={title}
|
||||||
|
onClose={onCancel}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onCancel} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={onConfirm} loading={loading}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-slate-600">{description}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
Components/UI/EmptyState.tsx
Normal file
17
Components/UI/EmptyState.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon = "📋", title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||||
|
<span className="text-5xl" aria-hidden="true">{icon}</span>
|
||||||
|
<h3 className="text-base font-semibold text-slate-700">{title}</h3>
|
||||||
|
{description && <p className="text-sm text-slate-500 max-w-xs">{description}</p>}
|
||||||
|
{action && <div className="mt-2">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
Components/UI/ModalProps.tsx
Normal file
54
Components/UI/ModalProps.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Footer slot – typically action buttons */
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODAL_SIZES = { sm: "max-w-sm", md: "max-w-lg", lg: "max-w-2xl" };
|
||||||
|
|
||||||
|
export function Modal({ open, title, onClose, children, footer, size = "md" }: ModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
className={`relative w-full ${MODAL_SIZES[size]} rounded-2xl bg-white shadow-2xl flex flex-col max-h-[90vh]`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||||
|
<h2 id="modal-title" className="text-base font-semibold text-slate-800">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="text-slate-400 hover:text-slate-600 transition-colors text-xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div className="flex justify-end gap-2 border-t border-slate-100 px-6 py-4">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
Components/UI/PageHeader.tsx
Normal file
29
Components/UI/PageHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
backHref?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, description, action, backHref, backLabel }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
{backHref && (
|
||||||
|
<a
|
||||||
|
href={backHref}
|
||||||
|
className="mb-2 inline-flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-800 font-medium"
|
||||||
|
>
|
||||||
|
← {backLabel ?? "Back"}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action && <div className="mt-3 sm:mt-0 shrink-0">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
Components/UI/Select.tsx
Normal file
52
Components/UI/Select.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { forwardRef, SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Select ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||||
|
{ label, error, wrapperClassName = "", className = "", id, children, ...rest },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const selectId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1 ${wrapperClassName}`}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={selectId}
|
||||||
|
className="text-xs font-semibold uppercase tracking-wide text-slate-500"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
id={selectId}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg border bg-white px-3 py-2 text-sm text-slate-800
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
|
||||||
|
disabled:bg-slate-50 disabled:cursor-not-allowed
|
||||||
|
${error ? "border-red-400" : "border-slate-300"}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
41
Components/UI/Textarea.tsx
Normal file
41
Components/UI/Textarea.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||||
|
{ label, error, wrapperClassName = "", className = "", id, ...rest },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const taId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1 ${wrapperClassName}`}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={taId}
|
||||||
|
className="text-xs font-semibold uppercase tracking-wide text-slate-500"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
id={taId}
|
||||||
|
rows={3}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg border bg-white px-3 py-2 text-sm text-slate-800
|
||||||
|
placeholder:text-slate-400 resize-none
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
|
||||||
|
disabled:bg-slate-50 disabled:text-slate-400
|
||||||
|
${error ? "border-red-400" : "border-slate-300"}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -10,3 +10,7 @@ export type { ButtonProps } from "./Button";
|
|||||||
|
|
||||||
export { Input } from "./Input";
|
export { Input } from "./Input";
|
||||||
export type { InputProps } from "./Input";
|
export type { InputProps } from "./Input";
|
||||||
|
|
||||||
|
export { Textarea } from "./Textarea";
|
||||||
|
|
||||||
|
export { Select } from "./Select";
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
export default function ClientHomePage() {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 text-slate-900">
|
|
||||||
<section className="flex min-h-screen">
|
|
||||||
<aside className="hidden w-18 border-r border-slate-200 bg-white lg:flex lg:flex-col lg:items-center lg:py-4">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-600 text-white">🚚</div>
|
|
||||||
<nav className="mt-6 flex flex-1 flex-col items-center gap-3 text-[9px] font-semibold text-slate-400">
|
|
||||||
{['Home','New Order','Track','History','Settings'].map((label, index) => (
|
|
||||||
<button key={label} type="button" className={`flex h-12 w-12 flex-col items-center justify-center rounded-xl ${index === 0 ? 'bg-blue-50 text-blue-600' : 'bg-transparent hover:bg-slate-100'}`}>
|
|
||||||
<span className="text-base">•</span>
|
|
||||||
<span>{['الرئيسية','طلب جديد','تتبع','السجل','الإعدادات'][index]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="flex-1 p-6 lg:p-8">
|
|
||||||
<header className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-[12px] uppercase tracking-[0.35em] text-blue-600">الصفحة الرئيسية للعميل</p>
|
|
||||||
<h1 className="mt-2 text-[24px] font-bold text-slate-900">صباح الخير يا عميل 👋</h1>
|
|
||||||
<p className="text-[13px] text-slate-500">نظرة عامة على عمليات الإرسال والنشاط الأخير للطلبات.</p>
|
|
||||||
</div>
|
|
||||||
<button className="h-10 rounded-lg bg-blue-600 px-4 text-sm font-semibold text-white">+ طلب جديد</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
|
||||||
{[
|
|
||||||
['الطلبات النشطة', '12', 'text-blue-600'],
|
|
||||||
['المسلمة هذا الشهر', '86', 'text-emerald-600'],
|
|
||||||
['العناوين المحفوظة', '4', 'text-slate-900'],
|
|
||||||
].map(([label, value, tone]) => (
|
|
||||||
<article key={label} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.35em] text-slate-400">{label}</p>
|
|
||||||
<p className={`mt-3 text-[26px] font-bold ${tone}`}>{value}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<article className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="text-[16px] font-semibold">الطلب النشط</h2>
|
|
||||||
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-slate-900">ORD-202606-00142</p>
|
|
||||||
<p className="text-[12px] text-slate-500">المركز الشمالي → مخزن وسط المدينة</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full bg-amber-100 px-3 py-1 text-[11px] font-bold text-amber-800">في الطريق</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 h-2 rounded-full bg-slate-200">
|
|
||||||
<div className="h-2 w-2/3 rounded-full bg-blue-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
|
||||||
<h2 className="text-[16px] font-semibold">الطلبات الأخيرة</h2>
|
|
||||||
<ul className="mt-4 divide-y divide-slate-200">
|
|
||||||
{[
|
|
||||||
['ORD-202606-00110', 'تم التوصيل', 'تم التوصيل', 'green'],
|
|
||||||
['ORD-202606-00115', 'ملغى', 'ملغى', 'red'],
|
|
||||||
].map(([ref, status, note, tone]) => (
|
|
||||||
<li key={ref} className="flex items-center justify-between gap-3 py-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-slate-900">{ref}</p>
|
|
||||||
<p className="text-[12px] text-slate-500">{note}</p>
|
|
||||||
</div>
|
|
||||||
<span className={`rounded-full px-3 py-1 text-[11px] font-bold ${tone === 'green' ? 'bg-emerald-100 text-emerald-800' : 'bg-rose-100 text-rose-800'}`}>{status}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
538
app/clients/[clientId]/addresses/page.tsx
Normal file
538
app/clients/[clientId]/addresses/page.tsx
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Alert } from "../../../../Components/UI";
|
||||||
|
import { Toast } from "../../../../Components/Client/Toast";
|
||||||
|
import { useClientAddresses } from "../../../../hooks/useClientAddresses";
|
||||||
|
import { clientService } from "../../../../services/client.service";
|
||||||
|
import { getStoredToken } from "../../../../lib/auth";
|
||||||
|
import type {
|
||||||
|
Client,
|
||||||
|
ClientAddress,
|
||||||
|
ClientAddressFormData,
|
||||||
|
} from "../../../../types/client";
|
||||||
|
import { AddressFormModal } from "@/Components/Client/Addressformmodal";
|
||||||
|
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal";
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns a context-appropriate emoji for each common address label */
|
||||||
|
function labelIcon(label: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"فوترة": "💳",
|
||||||
|
"شحن": "📦",
|
||||||
|
"المقر الرئيسي": "🏢",
|
||||||
|
"فرع": "🏬",
|
||||||
|
"مستودع": "🏭",
|
||||||
|
billing: "💳",
|
||||||
|
shipping: "📦",
|
||||||
|
"head office": "🏢",
|
||||||
|
branch: "🏬",
|
||||||
|
warehouse: "🏭",
|
||||||
|
};
|
||||||
|
return map[label.toLowerCase()] ?? "📍";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Address card sub-component ─────────────────────────────────────────────
|
||||||
|
// Mirrors the AddressCard in the old page — kept co-located because it's only used here.
|
||||||
|
interface AddressCardProps {
|
||||||
|
address: ClientAddress;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onMakePrimary: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: address.isPrimary
|
||||||
|
? "1px solid #93C5FD"
|
||||||
|
: "1px solid var(--color-border)",
|
||||||
|
padding: "1.25rem",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
boxShadow: address.isPrimary
|
||||||
|
? "0 0 0 2px #BFDBFE, var(--shadow-card)"
|
||||||
|
: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Label row */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 20 }} aria-hidden="true">
|
||||||
|
{labelIcon(address.label)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--color-text-primary)" }}>
|
||||||
|
{address.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{address.isPrimary && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#1D4ED8",
|
||||||
|
background: "#EFF6FF",
|
||||||
|
border: "1px solid #BFDBFE",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
padding: "0.15rem 0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
رئيسي
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address lines */}
|
||||||
|
<address
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
lineHeight: 1.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: 0 }}>{address.street}</p>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
{address.city}، {address.state} {address.postalCode}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0 }}>{address.country}</p>
|
||||||
|
</address>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActionBtn onClick={onEdit} color="#1D4ED8" bg="#EFF6FF" border="#BFDBFE">
|
||||||
|
تعديل
|
||||||
|
</ActionBtn>
|
||||||
|
|
||||||
|
{!address.isPrimary && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={onMakePrimary}
|
||||||
|
color="#065F46"
|
||||||
|
bg="#DCFCE7"
|
||||||
|
border="#BBF7D0"
|
||||||
|
>
|
||||||
|
تعيين كرئيسي
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ActionBtn
|
||||||
|
onClick={onDelete}
|
||||||
|
color="#DC2626"
|
||||||
|
bg="#FEF2F2"
|
||||||
|
border="#FECACA"
|
||||||
|
style={{ marginRight: "auto" }}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</ActionBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionBtn({
|
||||||
|
onClick,
|
||||||
|
color,
|
||||||
|
bg,
|
||||||
|
border,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
height: 30,
|
||||||
|
padding: "0 0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
background: bg,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ClientAddressesPage() {
|
||||||
|
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const clientId = params?.clientId as string;
|
||||||
|
console.log('clientId:', params.clientId); // شوف إيه اللي بيجي
|
||||||
|
// ── Parent client meta ───────────────────────────────────────────────────
|
||||||
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
|
const [clientLoading, setClientLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientId) return;
|
||||||
|
setClientLoading(true);
|
||||||
|
clientService
|
||||||
|
.getById(clientId, getStoredToken())
|
||||||
|
.then((res) => setClient(res.data))
|
||||||
|
.catch(() => router.replace("/clients"))
|
||||||
|
.finally(() => setClientLoading(false));
|
||||||
|
}, [clientId, router]);
|
||||||
|
|
||||||
|
// ── Address CRUD hook ────────────────────────────────────────────────────
|
||||||
|
const {
|
||||||
|
addresses,
|
||||||
|
loading: addrLoading,
|
||||||
|
error,
|
||||||
|
notification,
|
||||||
|
clearError,
|
||||||
|
createAddress,
|
||||||
|
updateAddress,
|
||||||
|
deleteAddress,
|
||||||
|
makePrimary,
|
||||||
|
} = useClientAddresses(clientId ?? "");
|
||||||
|
|
||||||
|
// ── Modal state (mirrors UsersPage pattern) ──────────────────────────────
|
||||||
|
// false = closed | null = create mode | ClientAddress = edit mode
|
||||||
|
const [formTarget, setFormTarget] = useState<ClientAddress | null | false>(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||||
|
const handleFormSubmit = async (
|
||||||
|
data: ClientAddressFormData,
|
||||||
|
isNew: boolean
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (isNew) return createAddress(data);
|
||||||
|
return updateAddress((formTarget as ClientAddress).id, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget || deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
const ok = await deleteAddress(deleteTarget.id);
|
||||||
|
setDeleting(false);
|
||||||
|
if (ok) setDeleteTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Guard: wait for router + client ─────────────────────────────────────
|
||||||
|
if (!clientId || clientLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "3px solid var(--color-border)",
|
||||||
|
borderTopColor: "var(--color-brand-600)",
|
||||||
|
animation: "spin 0.7s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Global success / error toast */}
|
||||||
|
<Toast notification={notification} />
|
||||||
|
|
||||||
|
{/* Create / Edit address modal */}
|
||||||
|
{formTarget !== false && (
|
||||||
|
<AddressFormModal
|
||||||
|
editAddress={formTarget}
|
||||||
|
onClose={() => setFormTarget(false)}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation — reuses the same DeleteConfirmModal shape */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
// Cast address as Client for the modal (same shape: id + name)
|
||||||
|
client={
|
||||||
|
{
|
||||||
|
...deleteTarget,
|
||||||
|
name: deleteTarget.label,
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
createdAt: deleteTarget.createdAt,
|
||||||
|
updatedAt: deleteTarget.updatedAt,
|
||||||
|
} as Client
|
||||||
|
}
|
||||||
|
deleting={deleting}
|
||||||
|
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
|
{/* ── Page header ── */}
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "1.5rem 2rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/clients")}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
marginBottom: 12,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
جميع العملاء
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إدارة العناوين
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{client ? `${client.name} — العناوين` : "العناوين"}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إجمالي{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{addresses.length}
|
||||||
|
</strong>{" "}
|
||||||
|
عنوان
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add address button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormTarget(null)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.125rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
إضافة عنوان
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client info strip */}
|
||||||
|
{client && (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "1rem",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
paddingTop: "1rem",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
|
||||||
|
{client.name}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={`mailto:${client.email}`}
|
||||||
|
style={{ color: "#2563EB", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
{client.email}
|
||||||
|
</a>
|
||||||
|
<span>{client.phone}</span>
|
||||||
|
{client.taxId && (
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
padding: "0.1rem 0.4rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{client.taxId}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* General load error */}
|
||||||
|
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||||
|
|
||||||
|
{/* Address grid */}
|
||||||
|
{addrLoading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "4rem 0",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
جارٍ التحميل…
|
||||||
|
</div>
|
||||||
|
) : addresses.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "4rem 1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: 32, margin: 0 }}>📍</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
لا توجد عناوين بعد
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات والفواتير.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormTarget(null)}
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
أضف أول عنوان
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{addresses.map((addr) => (
|
||||||
|
<AddressCard
|
||||||
|
key={addr.id}
|
||||||
|
address={addr}
|
||||||
|
onEdit={() => setFormTarget(addr)}
|
||||||
|
onDelete={() => setDeleteTarget(addr)}
|
||||||
|
onMakePrimary={() => makePrimary(addr.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
app/clients/page.tsx
Normal file
238
app/clients/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Alert } from "../../Components/UI";
|
||||||
|
import { Toast } from "../../Components/Client/Toast";
|
||||||
|
import { useClients } from "../../hooks/useClients";
|
||||||
|
import type { Client, ClientFormData } from "../../types/client";
|
||||||
|
import { ClientFormModal } from "@/Components/Client/Clientformmodal";
|
||||||
|
import { ClientTable } from "@/Components/Client/Clienttable";
|
||||||
|
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal";
|
||||||
|
|
||||||
|
export default function ClientsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ── Modal state ──────────────────────────────────────────────────────────
|
||||||
|
// false = closed | null = create mode | Client = edit mode
|
||||||
|
const [formTarget, setFormTarget] = useState<Client | null | false>(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Client | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// ── Data hook ────────────────────────────────────────────────────────────
|
||||||
|
const {
|
||||||
|
clients, loading, total, pages, error,
|
||||||
|
page, search,
|
||||||
|
setPage, handleSearch, clearError,
|
||||||
|
createClient, updateClient, deleteClient,
|
||||||
|
notification,
|
||||||
|
} = useClients();
|
||||||
|
|
||||||
|
// ── Create / Update handler ──────────────────────────────────────────────
|
||||||
|
const handleFormSubmit = async (
|
||||||
|
data: ClientFormData,
|
||||||
|
isNew: boolean
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (isNew) return createClient(data);
|
||||||
|
return updateClient((formTarget as Client).id, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Delete handler ───────────────────────────────────────────────────────
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget || deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
const ok = await deleteClient(deleteTarget.id);
|
||||||
|
setDeleting(false);
|
||||||
|
if (ok) setDeleteTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Navigate to address management ──────────────────────────────────────
|
||||||
|
const handleManageAddresses = (client: Client) => {
|
||||||
|
router.push(`/clients/${client.id}/addresses`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Global success / error toast */}
|
||||||
|
<Toast notification={notification} />
|
||||||
|
|
||||||
|
{/* Create / Edit modal */}
|
||||||
|
{formTarget !== false && (
|
||||||
|
<ClientFormModal
|
||||||
|
editClient={formTarget}
|
||||||
|
onClose={() => setFormTarget(false)}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
client={deleteTarget}
|
||||||
|
deleting={deleting}
|
||||||
|
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
|
{/* ── Page header ── */}
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "1.5rem 2rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إدارة العملاء
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
العملاء
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إجمالي{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{total}
|
||||||
|
</strong>{" "}
|
||||||
|
عميل
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search input */}
|
||||||
|
<div style={{ position: "relative", width: 256 }}>
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="بحث بالاسم أو الهاتف أو البريد…"
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add client button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormTarget(null)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.125rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
إضافة عميل
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* General load error */}
|
||||||
|
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||||
|
|
||||||
|
{/* Clients table */}
|
||||||
|
<ClientTable
|
||||||
|
clients={clients}
|
||||||
|
loading={loading}
|
||||||
|
search={search}
|
||||||
|
page={page}
|
||||||
|
pages={pages}
|
||||||
|
onEdit={(client) => setFormTarget(client)}
|
||||||
|
onDelete={(client) => setDeleteTarget(client)}
|
||||||
|
onAddFirst={() => setFormTarget(null)}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onManageAddresses={handleManageAddresses}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,52 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { getStoredToken } from "../../lib/auth";
|
|
||||||
import { get, post, put, del } from "../../services/api";
|
|
||||||
import { Spinner, Alert } from "../../Components/UI";
|
|
||||||
|
|
||||||
interface Driver {
|
import { useState, useCallback } from "react";
|
||||||
id: string;
|
import { Alert, Spinner } from "@/Components/UI";
|
||||||
name: string;
|
import { DriverFormModal } from "@/Components/Driver/DriverFormModal";
|
||||||
userName?: string;
|
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal";
|
||||||
phone: string;
|
import { driverService } from "@/services";
|
||||||
nationality?: string;
|
import { getStoredToken } from "@/lib/auth";
|
||||||
licenseNumber?: string;
|
import { useDrivers } from "@/hooks/useDriver";
|
||||||
licenseExpiry?: string;
|
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/types/driver";
|
||||||
nationalIdExpiry?: string;
|
import { DriverDetailPanel } from "@/Components/Driver/DriverDetailPanel";
|
||||||
status: "Active" | "Inactive" | "InTrip" | "Suspended";
|
|
||||||
isActive: boolean;
|
|
||||||
branch?: { name: string };
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiResponse {
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
data: {
|
|
||||||
data: Driver[];
|
|
||||||
pagination: { total: number; page: number; pages: number };
|
|
||||||
meta?: { total: number; pages: number };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<Driver["status"], { label: string; color: string; bg: string; border: string }> = {
|
function fmtDate(iso?: string | null): string {
|
||||||
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
|
|
||||||
InTrip: { label: "في رحلة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE" },
|
|
||||||
Inactive: { label: "غير نشط", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0" },
|
|
||||||
Suspended: { label: "موقوف", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmtDate(iso?: string) {
|
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
year: "numeric", month: "short", day: "numeric",
|
year: "numeric", month: "short", day: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function expirySoon(iso?: string) {
|
function expirySoon(iso?: string | null): boolean {
|
||||||
if (!iso) return false;
|
if (!iso) return false;
|
||||||
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
|
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)",
|
borderRadius: "var(--radius-xl)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
@@ -66,35 +46,81 @@ const thStyle: React.CSSProperties = {
|
|||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Page Component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function DriversPage() {
|
export default function DriversPage() {
|
||||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
const {
|
||||||
const [loading, setLoading] = useState(true);
|
drivers, loading, error, total, pages, page,
|
||||||
const [error, setError] = useState<string | null>(null);
|
search, setPage, handleSearch, clearError,
|
||||||
const [search, setSearch] = useState("");
|
deleteDriver, notification, reload,
|
||||||
const [page, setPage] = useState(1);
|
} = useDrivers();
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [pages, setPages] = useState(1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// ── Panel / modal state ───────────────────────────────────────────────────
|
||||||
|
const [selectedDriverId, setSelectedDriverId] = useState<string | null>(null);
|
||||||
|
const [formDriver, setFormDriver] = useState<Driver | null | "new">(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
const handleEdit = useCallback((driver: Driver) => {
|
||||||
|
setSelectedDriverId(null);
|
||||||
|
setFormDriver(driver);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = useCallback((driver: Driver) => {
|
||||||
|
setSelectedDriverId(null);
|
||||||
|
setDeleteTarget(driver);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
async (
|
||||||
|
payload: CreateDriverPayload | UpdateDriverPayload,
|
||||||
|
isNew: boolean,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
setLoading(true);
|
if (isNew) {
|
||||||
setError(null);
|
// Use multipart if there are file fields
|
||||||
const query = `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
const hasFiles =
|
||||||
get<ApiResponse>(`driver${query}`, token)
|
(payload as CreateDriverPayload & { photo?: File }).photo ||
|
||||||
.then((res) => {
|
(payload as CreateDriverPayload & { nationalPhoto?: File }).nationalPhoto ||
|
||||||
const payload = res.data ?? res;
|
(payload as CreateDriverPayload & { driverCardPhoto?: File }).driverCardPhoto;
|
||||||
setDrivers(payload.data ?? []);
|
|
||||||
setTotal(payload.meta?.total ?? 0);
|
|
||||||
setPages(payload.meta?.pages ?? 1);
|
|
||||||
})
|
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [page, search]);
|
|
||||||
|
|
||||||
|
if (hasFiles) {
|
||||||
|
await driverService.createWithImages(
|
||||||
|
payload as Parameters<typeof driverService.createWithImages>[0],
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await driverService.create(payload as CreateDriverPayload, token);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const id = (formDriver as Driver).id;
|
||||||
|
await driverService.update(id, payload as UpdateDriverPayload, token);
|
||||||
|
}
|
||||||
|
reload();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[formDriver, reload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
const ok = await deleteDriver(deleteTarget.id);
|
||||||
|
setDeleting(false);
|
||||||
|
if (ok) setDeleteTarget(null);
|
||||||
|
}, [deleteTarget, deleteDriver]);
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* ── Header ── */}
|
||||||
<header style={{
|
<header style={{
|
||||||
borderRadius: "var(--radius-xl)",
|
borderRadius: "var(--radius-xl)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
@@ -105,22 +131,34 @@ export default function DriversPage() {
|
|||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||||
إدارة الكوادر
|
إدارة الكوادر
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
<div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
السائقون
|
السائقون
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> سائق مسجل
|
إجمالي{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "}
|
||||||
|
سائق مسجل
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: "relative", width: "100%", maxWidth: 288 }}>
|
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
{/* Search */}
|
||||||
|
<div style={{ position: "relative", width: 288 }}>
|
||||||
|
<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" />
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" placeholder="بحث بالاسم أو الهاتف..." value={search}
|
<input
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }} dir="rtl"
|
type="text"
|
||||||
|
placeholder="بحث بالاسم أو الهاتف..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
style={{
|
style={{
|
||||||
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
|
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
|
||||||
borderRadius: "var(--radius-lg)",
|
borderRadius: "var(--radius-lg)",
|
||||||
@@ -131,24 +169,60 @@ export default function DriversPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormDriver("new")}
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 1.25rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
إضافة سائق
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
|
{/* ── Notifications ── */}
|
||||||
|
{notification && (
|
||||||
|
<Alert
|
||||||
|
type={notification.type}
|
||||||
|
message={notification.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert type="error" message={error} onClose={clearError} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* ── Table ── */}
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<div dir="rtl" style={{
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "2fr 1.2fr 1fr 1fr 1fr 1fr",
|
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
|
||||||
...thStyle,
|
...thStyle,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<span>السائق</span>
|
<span>السائق</span>
|
||||||
<span>الفرع</span>
|
<span>الفرع</span>
|
||||||
<span>الجنسية</span>
|
<span>الجنسية</span>
|
||||||
<span>انتهاء الرخصة</span>
|
<span>انتهاء الرخصة</span>
|
||||||
|
<span>انتهاء الهوية</span>
|
||||||
<span>الحالة</span>
|
<span>الحالة</span>
|
||||||
<span style={{ textAlign: "center" }}>انتهاء الهوية</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -163,47 +237,62 @@ export default function DriversPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
{drivers.map((d, i) => {
|
{drivers.map((d, i) => {
|
||||||
const status = STATUS_MAP[d.status];
|
const statusCfg = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||||
const licWarn = expirySoon(d.licenseExpiry);
|
const licWarn = expirySoon(d.licenseExpiry);
|
||||||
const idWarn = expirySoon(d.nationalIdExpiry);
|
const idWarn = expirySoon((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={d.id} style={{
|
<li
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => setSelectedDriverId(d.id)}
|
||||||
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "2fr 1.2fr 1fr 1fr 1fr 1fr",
|
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
|
||||||
alignItems: "center", gap: "0.5rem",
|
alignItems: "center", gap: "0.5rem",
|
||||||
padding: "0.875rem 1.5rem",
|
padding: "0.875rem 1.5rem",
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}>
|
cursor: "pointer",
|
||||||
|
transition: "background 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")}
|
||||||
|
>
|
||||||
|
{/* Name + phone */}
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
|
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Branch */}
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
|
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
|
||||||
|
|
||||||
|
{/* Nationality */}
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span>
|
<span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span>
|
||||||
<span style={{
|
|
||||||
fontSize: 12, fontWeight: 600,
|
{/* License expiry */}
|
||||||
color: licWarn ? "#D97706" : "var(--color-text-secondary)",
|
<span style={{ fontSize: 12, fontWeight: licWarn ? 600 : 400, color: licWarn ? "#D97706" : "var(--color-text-secondary)" }}>
|
||||||
}}>
|
|
||||||
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
|
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* National ID expiry */}
|
||||||
|
<span style={{ fontSize: 12, fontWeight: idWarn ? 600 : 400, color: idWarn ? "#D97706" : "var(--color-text-secondary)" }}>
|
||||||
|
{idWarn && "⚠ "}{fmtDate((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
<span style={{
|
<span style={{
|
||||||
display: "inline-flex", alignItems: "center",
|
display: "inline-flex", alignItems: "center", gap: 5,
|
||||||
borderRadius: "var(--radius-full)",
|
borderRadius: "var(--radius-full)",
|
||||||
border: `1px solid ${status.border}`,
|
border: `1px solid ${statusCfg.border}`,
|
||||||
background: status.bg,
|
background: statusCfg.bg,
|
||||||
padding: "0.2rem 0.625rem",
|
padding: "0.2rem 0.625rem",
|
||||||
fontSize: 11, fontWeight: 600, color: status.color,
|
fontSize: 11, fontWeight: 600, color: statusCfg.color,
|
||||||
width: "fit-content",
|
width: "fit-content",
|
||||||
}}>
|
}}>
|
||||||
{status.label}
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} />
|
||||||
</span>
|
{statusCfg.label}
|
||||||
<span style={{
|
|
||||||
textAlign: "center", fontSize: 12, fontWeight: 600,
|
|
||||||
color: idWarn ? "#D97706" : "var(--color-text-secondary)",
|
|
||||||
}}>
|
|
||||||
{idWarn && "⚠ "}{fmtDate(d.nationalIdExpiry)}
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -211,20 +300,30 @@ export default function DriversPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Pagination ── */}
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div dir="rtl" style={{
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
|
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
صفحة{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "}
|
||||||
|
من{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
{[
|
||||||
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
|
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
|
||||||
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
|
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
|
||||||
].map(btn => (
|
].map(btn => (
|
||||||
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
|
<button
|
||||||
|
key={btn.label}
|
||||||
|
onClick={btn.action}
|
||||||
|
disabled={btn.disabled}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: "var(--radius-lg)",
|
borderRadius: "var(--radius-lg)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
@@ -234,7 +333,8 @@ export default function DriversPage() {
|
|||||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
cursor: btn.disabled ? "not-allowed" : "pointer",
|
||||||
opacity: btn.disabled ? 0.4 : 1,
|
opacity: btn.disabled ? 0.4 : 1,
|
||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{btn.label}
|
{btn.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -243,5 +343,36 @@ export default function DriversPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Detail panel ── */}
|
||||||
|
{selectedDriverId && (
|
||||||
|
<DriverDetailPanel
|
||||||
|
driverId={selectedDriverId}
|
||||||
|
onClose={() => setSelectedDriverId(null)}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Form modal ── */}
|
||||||
|
{formDriver !== null && (
|
||||||
|
<DriverFormModal
|
||||||
|
editDriver={formDriver === "new" ? null : formDriver}
|
||||||
|
branches={[]}
|
||||||
|
onClose={() => setFormDriver(null)}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Delete modal ── */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DriverDeleteModal
|
||||||
|
driver={deleteTarget}
|
||||||
|
deleting={deleting}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
184
hooks/useClientAddresses.ts
Normal file
184
hooks/useClientAddresses.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
|
import { getStoredToken } from "../lib/auth";
|
||||||
|
import { clientAddressService } from "../services/clientAddress.service";
|
||||||
|
import type {
|
||||||
|
ClientAddress,
|
||||||
|
ClientAddressFormData,
|
||||||
|
AddressTableAction,
|
||||||
|
AddressTableState,
|
||||||
|
} from "../types/client";
|
||||||
|
|
||||||
|
// ── Notification ───────────────────────────────────────────────────────────
|
||||||
|
export interface Notification {
|
||||||
|
type: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reducer ────────────────────────────────────────────────────────────────
|
||||||
|
function reducer(s: AddressTableState, a: AddressTableAction): AddressTableState {
|
||||||
|
switch (a.type) {
|
||||||
|
case "LOAD_START":
|
||||||
|
return { ...s, loading: true, error: null };
|
||||||
|
case "LOAD_OK":
|
||||||
|
return { ...s, loading: false, addresses: a.addresses };
|
||||||
|
case "LOAD_ERR":
|
||||||
|
return { ...s, loading: false, error: a.error };
|
||||||
|
case "ADD":
|
||||||
|
return { ...s, addresses: [...s.addresses, a.address] };
|
||||||
|
case "UPDATE":
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
addresses: s.addresses.map((a2) =>
|
||||||
|
a2.id === a.address.id ? a.address : a2
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "DELETE":
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
addresses: s.addresses.filter((a2) => a2.id !== a.id),
|
||||||
|
};
|
||||||
|
case "CLEAR_ERR":
|
||||||
|
return { ...s, error: null };
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AddressTableState = {
|
||||||
|
addresses: [],
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main hook ──────────────────────────────────────────────────────────────
|
||||||
|
export function useClientAddresses(clientId: string) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const [notification, setNotification] = useState<Notification | null>(null);
|
||||||
|
|
||||||
|
/** Show a toast for 4 seconds then auto-dismiss. */
|
||||||
|
const notify = useCallback((n: Notification) => {
|
||||||
|
setNotification(n);
|
||||||
|
setTimeout(() => setNotification(null), 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Fetch addresses ──────────────────────────────────────────────────────
|
||||||
|
const loadAddresses = useCallback(async () => {
|
||||||
|
if (!clientId) return;
|
||||||
|
dispatch({ type: "LOAD_START" });
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await clientAddressService.getAll(clientId, token);
|
||||||
|
const payload = (res as any).data ?? res;
|
||||||
|
dispatch({
|
||||||
|
type: "LOAD_OK",
|
||||||
|
addresses: Array.isArray(payload) ? payload : (payload.data ?? []),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
dispatch({
|
||||||
|
type: "LOAD_ERR",
|
||||||
|
error: "تعذّر تحميل العناوين. يرجى المحاولة مجدداً.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAddresses();
|
||||||
|
}, [loadAddresses]);
|
||||||
|
|
||||||
|
// ── CREATE ───────────────────────────────────────────────────────────────
|
||||||
|
const createAddress = useCallback(
|
||||||
|
async (data: ClientAddressFormData): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await clientAddressService.create(clientId, data, token);
|
||||||
|
dispatch({ type: "ADD", address: res.data });
|
||||||
|
notify({ type: "success", message: "تم إضافة العنوان بنجاح." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر إضافة العنوان.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clientId, notify],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── UPDATE ───────────────────────────────────────────────────────────────
|
||||||
|
const updateAddress = useCallback(
|
||||||
|
async (id: string, data: ClientAddressFormData): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await clientAddressService.update(clientId, id, data, token);
|
||||||
|
dispatch({ type: "UPDATE", address: res.data });
|
||||||
|
notify({ type: "success", message: "تم تحديث العنوان." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر تحديث العنوان.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clientId, notify],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── DELETE ───────────────────────────────────────────────────────────────
|
||||||
|
const deleteAddress = useCallback(
|
||||||
|
async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
await clientAddressService.delete(clientId, id, token);
|
||||||
|
dispatch({ type: "DELETE", id });
|
||||||
|
notify({ type: "success", message: "تم حذف العنوان بنجاح." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر حذف العنوان.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clientId, notify],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── SET PRIMARY ──────────────────────────────────────────────────────────
|
||||||
|
const makePrimary = useCallback(
|
||||||
|
async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await clientAddressService.setPrimary(clientId, id, token);
|
||||||
|
// Backend demotes all others — reload to sync
|
||||||
|
dispatch({ type: "UPDATE", address: res.data });
|
||||||
|
// Reload to reflect the backend's cascade demotions
|
||||||
|
await loadAddresses();
|
||||||
|
notify({ type: "success", message: "تم تعيين العنوان الرئيسي." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر تعيين العنوان الرئيسي.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clientId, notify, loadAddresses],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
notification,
|
||||||
|
clearError: () => dispatch({ type: "CLEAR_ERR" }),
|
||||||
|
createAddress,
|
||||||
|
updateAddress,
|
||||||
|
deleteAddress,
|
||||||
|
makePrimary,
|
||||||
|
reload: loadAddresses,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { clientService } from "@/services/client.service";
|
|
||||||
import type { Order } from "../types/client";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
orders: Order[];
|
|
||||||
error: string | null;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all orders for a client session.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const { orders, loading, error } = useClientOrders(session.id);
|
|
||||||
*/
|
|
||||||
export function useClientOrders(clientId: string): State {
|
|
||||||
const [state, setState] = useState<State>({
|
|
||||||
orders: [], error: null, loading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!clientId) {
|
|
||||||
setState({ orders: [], error: null, loading: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
clientService
|
|
||||||
.getOrders(clientId)
|
|
||||||
.then(orders => {
|
|
||||||
if (!cancelled) setState({ orders, error: null, loading: false });
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
if (!cancelled) setState({ orders: [], error: err.message, loading: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [clientId]);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
191
hooks/useClients.ts
Normal file
191
hooks/useClients.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
|
import { getStoredToken } from "../lib/auth";
|
||||||
|
import { clientService } from "../services/client.service";
|
||||||
|
import type {
|
||||||
|
Client,
|
||||||
|
ClientFormData,
|
||||||
|
ClientTableAction,
|
||||||
|
ClientTableState,
|
||||||
|
} from "../types/client";
|
||||||
|
|
||||||
|
// ── Notification (mirrors useUsers Notification) ───────────────────────────
|
||||||
|
export interface Notification {
|
||||||
|
type: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reducer ────────────────────────────────────────────────────────────────
|
||||||
|
function reducer(s: ClientTableState, a: ClientTableAction): ClientTableState {
|
||||||
|
switch (a.type) {
|
||||||
|
case "LOAD_START":
|
||||||
|
return { ...s, loading: true, error: null };
|
||||||
|
case "LOAD_OK":
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
loading: false,
|
||||||
|
clients: a.clients,
|
||||||
|
total: a.total,
|
||||||
|
pages: a.pages,
|
||||||
|
};
|
||||||
|
case "LOAD_ERR":
|
||||||
|
return { ...s, loading: false, error: a.error };
|
||||||
|
case "ADD":
|
||||||
|
return { ...s, clients: [a.client, ...s.clients], total: s.total + 1 };
|
||||||
|
case "UPDATE":
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
clients: s.clients.map((c) => (c.id === a.client.id ? a.client : c)),
|
||||||
|
};
|
||||||
|
case "DELETE":
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
clients: s.clients.filter((c) => c.id !== a.id),
|
||||||
|
total: Math.max(0, s.total - 1),
|
||||||
|
};
|
||||||
|
case "CLEAR_ERR":
|
||||||
|
return { ...s, error: null };
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ClientTableState = {
|
||||||
|
clients: [],
|
||||||
|
loading: true,
|
||||||
|
total: 0,
|
||||||
|
pages: 1,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main hook ──────────────────────────────────────────────────────────────
|
||||||
|
export function useClients() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [notification, setNotification] = useState<Notification | null>(null);
|
||||||
|
const [roles, setRoles] = useState<[]>([]); // placeholder — extend if needed
|
||||||
|
const [branches, setBranches] = useState<[]>([]); // placeholder — extend if needed
|
||||||
|
|
||||||
|
/** Show a toast for 4 seconds then auto-dismiss — identical to useUsers. */
|
||||||
|
const notify = useCallback((n: Notification) => {
|
||||||
|
setNotification(n);
|
||||||
|
setTimeout(() => setNotification(null), 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Fetch clients ────────────────────────────────────────────────────────
|
||||||
|
const loadClients = useCallback(async (p: number, q: string) => {
|
||||||
|
dispatch({ type: "LOAD_START" });
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await clientService.getAll(p, q, token);
|
||||||
|
|
||||||
|
// Normalise both pagination shapes the backend might return
|
||||||
|
const payload = (res as any).data ?? res;
|
||||||
|
dispatch({
|
||||||
|
type: "LOAD_OK",
|
||||||
|
clients: payload.data ?? [],
|
||||||
|
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
|
||||||
|
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
dispatch({
|
||||||
|
type: "LOAD_ERR",
|
||||||
|
error: "تعذّر تحميل بيانات العملاء. يرجى المحاولة مجدداً.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reload whenever page or search changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadClients(page, search);
|
||||||
|
}, [page, search, loadClients]);
|
||||||
|
|
||||||
|
// ── Search helper (resets to page 1) ────────────────────────────────────
|
||||||
|
const handleSearch = useCallback((q: string) => {
|
||||||
|
setSearch(q);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── CREATE ───────────────────────────────────────────────────────────────
|
||||||
|
const createClient = useCallback(
|
||||||
|
async (data: ClientFormData): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await clientService.create(data, token);
|
||||||
|
dispatch({ type: "ADD", client: res.data });
|
||||||
|
notify({ type: "success", message: "تم إضافة العميل بنجاح." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر إضافة العميل.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notify],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── UPDATE ───────────────────────────────────────────────────────────────
|
||||||
|
const updateClient = useCallback(
|
||||||
|
async (id: string, data: ClientFormData): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await clientService.update(id, data, token);
|
||||||
|
dispatch({ type: "UPDATE", client: res.data });
|
||||||
|
notify({ type: "success", message: "تم تحديث بيانات العميل." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر تحديث العميل.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notify],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── DELETE ───────────────────────────────────────────────────────────────
|
||||||
|
const deleteClient = useCallback(
|
||||||
|
async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
await clientService.delete(id, token);
|
||||||
|
dispatch({ type: "DELETE", id });
|
||||||
|
notify({ type: "success", message: "تم حذف العميل بنجاح." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر حذف العميل.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notify],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// table state
|
||||||
|
...state,
|
||||||
|
// pagination & search
|
||||||
|
page,
|
||||||
|
search,
|
||||||
|
setPage,
|
||||||
|
handleSearch,
|
||||||
|
clearError: () => dispatch({ type: "CLEAR_ERR" }),
|
||||||
|
// CRUD
|
||||||
|
createClient,
|
||||||
|
updateClient,
|
||||||
|
deleteClient,
|
||||||
|
// toast
|
||||||
|
notification,
|
||||||
|
// extras (currently unused, kept for future parity with useUsers)
|
||||||
|
roles,
|
||||||
|
branches,
|
||||||
|
reload: () => loadClients(page, search),
|
||||||
|
};
|
||||||
|
}
|
||||||
152
hooks/useDriver.ts
Normal file
152
hooks/useDriver.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
|
import { getStoredToken } from "../lib/auth";
|
||||||
|
import { driverService } from "../services/driver.service";
|
||||||
|
import type { Driver } from "../types/driver";
|
||||||
|
|
||||||
|
// ── Notification type ──────────────────────────────────────────────────────
|
||||||
|
export interface DriverNotification {
|
||||||
|
type: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table state / reducer ──────────────────────────────────────────────────
|
||||||
|
interface TableState {
|
||||||
|
drivers: Driver[];
|
||||||
|
loading: boolean;
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableAction =
|
||||||
|
| { type: "LOAD_START" }
|
||||||
|
| { type: "LOAD_OK"; drivers: Driver[]; total: number; pages: number }
|
||||||
|
| { type: "LOAD_ERR"; error: string }
|
||||||
|
| { type: "DELETE"; id: string }
|
||||||
|
| { type: "CLEAR_ERR" };
|
||||||
|
|
||||||
|
function reducer(s: TableState, a: TableAction): TableState {
|
||||||
|
switch (a.type) {
|
||||||
|
case "LOAD_START":
|
||||||
|
return { ...s, loading: true, error: null };
|
||||||
|
case "LOAD_OK":
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
loading: false,
|
||||||
|
drivers: a.drivers,
|
||||||
|
total: a.total,
|
||||||
|
pages: a.pages,
|
||||||
|
};
|
||||||
|
case "LOAD_ERR":
|
||||||
|
return { ...s, loading: false, error: a.error };
|
||||||
|
case "DELETE":
|
||||||
|
return { ...s, drivers: s.drivers.filter((d) => d.id !== a.id) };
|
||||||
|
case "CLEAR_ERR":
|
||||||
|
return { ...s, error: null };
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TableState = {
|
||||||
|
drivers: [],
|
||||||
|
loading: true,
|
||||||
|
total: 0,
|
||||||
|
pages: 1,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main hook ──────────────────────────────────────────────────────────────
|
||||||
|
export function useDrivers() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [notification, setNotification] =
|
||||||
|
useState<DriverNotification | null>(null);
|
||||||
|
|
||||||
|
/** Show a toast for 4 seconds then auto-dismiss. */
|
||||||
|
const notify = useCallback((n: DriverNotification) => {
|
||||||
|
setNotification(n);
|
||||||
|
setTimeout(() => setNotification(null), 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Fetch drivers ─────────────────────────────────────────────────────────
|
||||||
|
const loadDrivers = useCallback(async (p: number, q: string) => {
|
||||||
|
dispatch({ type: "LOAD_START" });
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await driverService.getAll(p, q, token);
|
||||||
|
// The response shape from the backend:
|
||||||
|
// { data: { data: Driver[], pagination: { total, page, pages }, meta?: ... } }
|
||||||
|
const payload = (
|
||||||
|
res as unknown as {
|
||||||
|
data: {
|
||||||
|
data: Driver[];
|
||||||
|
pagination?: { total: number; pages: number };
|
||||||
|
meta?: { total: number; pages: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).data ?? res;
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "LOAD_OK",
|
||||||
|
drivers: payload.data ?? [],
|
||||||
|
total:
|
||||||
|
payload.meta?.total ?? payload.pagination?.total ?? 0,
|
||||||
|
pages:
|
||||||
|
payload.meta?.pages ?? payload.pagination?.pages ?? 1,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
dispatch({
|
||||||
|
type: "LOAD_ERR",
|
||||||
|
error: "تعذّر تحميل بيانات السائقين. يرجى المحاولة مجدداً.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reload whenever page or search changes.
|
||||||
|
useEffect(() => {
|
||||||
|
loadDrivers(page, search);
|
||||||
|
}, [page, search, loadDrivers]);
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
const deleteDriver = useCallback(
|
||||||
|
async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
await driverService.delete(id, token);
|
||||||
|
dispatch({ type: "DELETE", id });
|
||||||
|
notify({ type: "success", message: "تم حذف السائق بنجاح." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
err instanceof Error ? err.message : "تعذّر حذف السائق.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notify],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Search helper ─────────────────────────────────────────────────────────
|
||||||
|
const handleSearch = useCallback((q: string) => {
|
||||||
|
setSearch(q);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
page,
|
||||||
|
search,
|
||||||
|
setPage,
|
||||||
|
handleSearch,
|
||||||
|
clearError: () => dispatch({ type: "CLEAR_ERR" }),
|
||||||
|
deleteDriver,
|
||||||
|
notification,
|
||||||
|
reload: () => loadDrivers(page, search),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
// ─────────────────────────────────────────────
|
|
||||||
// next.config.ts
|
|
||||||
// Proxy للـ API عشان نتفادى CORS في المتصفح
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
|||||||
@@ -1,46 +1,48 @@
|
|||||||
// services/client.service.ts
|
// خدمة العملاء — كل نداءات الـ API الخاصة بالعملاء تكون هنا فقط
|
||||||
|
// Mirrors user.service.ts exactly: token parameter, buildPayload, buildQuery.
|
||||||
|
import { get, post, put, del } from "./api";
|
||||||
|
import type { ApiResponse, ApiListResponse, Client, ClientFormData } from "../types/client";
|
||||||
|
|
||||||
import { get, post } from "./api";
|
/** نوع الاستجابة لعملية واحدة على عميل */
|
||||||
import type { ClientAddress, Order } from "../types/client";
|
export interface ClientResponse {
|
||||||
|
data: Client;
|
||||||
export interface CreateClientPayload {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
companyName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAddressPayload {
|
/** بناء query string لجلب العملاء مع البحث والصفحات */
|
||||||
label: string;
|
function buildClientsQuery(page: number, search: string): string {
|
||||||
branchName?: string | null;
|
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||||
contactPerson: { name: string; phone: string };
|
|
||||||
details: {
|
|
||||||
city: string;
|
|
||||||
district?: string;
|
|
||||||
street: string;
|
|
||||||
buildingNo?: string;
|
|
||||||
unitNo?: string;
|
|
||||||
zipCode?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clientService = {
|
export const clientService = {
|
||||||
/** Register a new client account */
|
/** جلب قائمة العملاء مع إمكانية البحث والتصفح بين الصفحات */
|
||||||
create: (payload: CreateClientPayload) =>
|
getAll: (page: number, search: string, token: string | null) =>
|
||||||
post<{ id: string; name: string; email: string; phone: string }>(
|
get<ApiListResponse<Client>>(`client${buildClientsQuery(page, search)}`, token),
|
||||||
"client",
|
|
||||||
payload,
|
|
||||||
),
|
|
||||||
|
|
||||||
/** Fetch all saved delivery addresses for a client */
|
/** جلب عميل واحد بالـ ID (مع العناوين المرفقة) */
|
||||||
getAddresses: (clientId: string) =>
|
getById: (id: string, token: string | null) =>
|
||||||
get<ClientAddress[]>(`client/${clientId}/addresses`),
|
get<ApiResponse<Client>>(`client/${id}`, token),
|
||||||
|
|
||||||
/** Save a new delivery address */
|
/** إنشاء عميل جديد */
|
||||||
addAddress: (clientId: string, payload: CreateAddressPayload) =>
|
create: (data: ClientFormData, token: string | null) =>
|
||||||
post<ClientAddress>(`client/${clientId}/addresses`, payload),
|
post<ClientResponse>("client", buildPayload(data), token),
|
||||||
|
|
||||||
/** Fetch all orders placed by a client */
|
/** تعديل بيانات عميل موجود */
|
||||||
getOrders: (clientId: string) =>
|
update: (id: string, data: ClientFormData, token: string | null) =>
|
||||||
get<Order[]>(`client/${clientId}/orders`),
|
put<ClientResponse>(`client/${id}`, buildPayload(data), token),
|
||||||
|
|
||||||
|
/** حذف عميل */
|
||||||
|
delete: (id: string, token: string | null) =>
|
||||||
|
del<void>(`client/${id}`, token),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
|
||||||
|
function buildPayload(data: ClientFormData): Record<string, string> {
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
name: data.name.trim(),
|
||||||
|
email: data.email.trim(),
|
||||||
|
phone: data.phone.trim(),
|
||||||
|
};
|
||||||
|
if (data.taxId?.trim()) payload.taxId = data.taxId.trim();
|
||||||
|
if (data.notes?.trim()) payload.notes = data.notes.trim();
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
50
services/clientAddress.service.ts
Normal file
50
services/clientAddress.service.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// خدمة عناوين العملاء — كل نداءات الـ API الخاصة بالعناوين تكون هنا فقط
|
||||||
|
// Mirrors user.service.ts pattern: explicit token param, buildPayload helper.
|
||||||
|
import { get, post, put, patch, del } from "./api";
|
||||||
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
ClientAddress,
|
||||||
|
ClientAddressFormData,
|
||||||
|
} from "../types/client";
|
||||||
|
|
||||||
|
const base = (clientId: string) => `client/${clientId}/addresses`;
|
||||||
|
|
||||||
|
export const clientAddressService = {
|
||||||
|
/** جلب جميع عناوين عميل معين */
|
||||||
|
getAll: (clientId: string, token: string | null) =>
|
||||||
|
get<ApiResponse<ClientAddress[]>>(base(clientId), token),
|
||||||
|
|
||||||
|
/** جلب عنوان واحد بالـ ID */
|
||||||
|
getById: (clientId: string, addressId: string, token: string | null) =>
|
||||||
|
get<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}`, token),
|
||||||
|
|
||||||
|
/** إضافة عنوان جديد لعميل */
|
||||||
|
create: (clientId: string, data: ClientAddressFormData, token: string | null) =>
|
||||||
|
post<ApiResponse<ClientAddress>>(base(clientId), buildPayload(clientId, data), token),
|
||||||
|
|
||||||
|
/** تعديل عنوان موجود */
|
||||||
|
update: (clientId: string, addressId: string, data: ClientAddressFormData, token: string | null) =>
|
||||||
|
put<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}`, buildPayload(clientId, data), token),
|
||||||
|
|
||||||
|
/** حذف عنوان */
|
||||||
|
delete: (clientId: string, addressId: string, token: string | null) =>
|
||||||
|
del<void>(`${base(clientId)}/${addressId}`, token),
|
||||||
|
|
||||||
|
/** تعيين عنوان كعنوان رئيسي — الباكند يُلغي الاختيار عن الباقين */
|
||||||
|
setPrimary: (clientId: string, addressId: string, token: string | null) =>
|
||||||
|
patch<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}/primary`, {}, token),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** تحويل بيانات النموذج إلى payload */
|
||||||
|
function buildPayload(clientId: string, data: ClientAddressFormData): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
label: data.label.trim(),
|
||||||
|
street: data.street.trim(),
|
||||||
|
city: data.city.trim(),
|
||||||
|
state: data.state.trim(),
|
||||||
|
postalCode: data.postalCode.trim(),
|
||||||
|
country: data.country.trim(),
|
||||||
|
isPrimary: data.isPrimary,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,154 @@
|
|||||||
export const driverService = {};
|
// services/driver.service.ts
|
||||||
|
// All API calls for the Driver module.
|
||||||
|
// Endpoints extracted from:
|
||||||
|
// - src/routes/driver.route.js → /api/v1/driver/...
|
||||||
|
// - src/routes/driver_report.route.js → /api/v1/drivers/:id/reports/daily
|
||||||
|
|
||||||
|
import { get, post, put, del } from "./api";
|
||||||
|
import type {
|
||||||
|
Driver,
|
||||||
|
DriverListResponse,
|
||||||
|
DriverDetailResponse,
|
||||||
|
DriverReportResponse,
|
||||||
|
CreateDriverPayload,
|
||||||
|
UpdateDriverPayload,
|
||||||
|
} from "../types/driver";
|
||||||
|
|
||||||
|
/** Build a query string for list endpoints */
|
||||||
|
function buildQuery(
|
||||||
|
params: Record<string, string | number | undefined>,
|
||||||
|
): string {
|
||||||
|
const entries = Object.entries(params).filter(
|
||||||
|
([, v]) => v !== undefined && v !== "",
|
||||||
|
);
|
||||||
|
if (!entries.length) return "";
|
||||||
|
return (
|
||||||
|
"?" +
|
||||||
|
entries
|
||||||
|
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
||||||
|
.join("&")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const driverService = {
|
||||||
|
/**
|
||||||
|
* GET /driver
|
||||||
|
* Fetch paginated + searchable driver list.
|
||||||
|
* Requires: "read-driver" permission
|
||||||
|
*/
|
||||||
|
getAll: (page = 1, search = "", token: string | null) =>
|
||||||
|
get<DriverListResponse>(
|
||||||
|
`driver${buildQuery({ page, limit: 12, search: search || undefined })}`,
|
||||||
|
token,
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /driver/archived
|
||||||
|
* Fetch soft-deleted (archived) drivers.
|
||||||
|
* Requires: "read-deleted-driver" permission
|
||||||
|
*/
|
||||||
|
getArchived: (token: string | null) =>
|
||||||
|
get<DriverListResponse>("driver/archived", token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /driver/me
|
||||||
|
* Fetch the currently authenticated driver's info.
|
||||||
|
* Requires: "read-driver" permission
|
||||||
|
*/
|
||||||
|
getMe: (token: string | null) =>
|
||||||
|
get<DriverDetailResponse>("driver/me", token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /driver/:id
|
||||||
|
* Fetch a single driver with status history and branch info.
|
||||||
|
* Requires: "read-driver" permission
|
||||||
|
*/
|
||||||
|
getById: (id: string, token: string | null) =>
|
||||||
|
get<DriverDetailResponse>(`driver/${id}`, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /driver
|
||||||
|
* Create a new driver (also generates a random userName & password).
|
||||||
|
* Requires: "create-driver" permission
|
||||||
|
* Note: for file uploads (photo / nationalPhoto / driverCardPhoto) use uploadWithImages.
|
||||||
|
*/
|
||||||
|
create: (payload: CreateDriverPayload, token: string | null) =>
|
||||||
|
post<{ data: Driver }>("driver", payload, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /driver/:id
|
||||||
|
* Update driver data or status.
|
||||||
|
* Requires: "update-driver" permission
|
||||||
|
*/
|
||||||
|
update: (id: string, payload: UpdateDriverPayload, token: string | null) =>
|
||||||
|
put<{ data: Driver }>(`driver/${id}`, payload, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /driver/:id
|
||||||
|
* Soft-delete a driver.
|
||||||
|
* Requires: "delete-driver" permission
|
||||||
|
*/
|
||||||
|
delete: (id: string, token: string | null) =>
|
||||||
|
del<void>(`driver/${id}`, token),
|
||||||
|
|
||||||
|
// ── Reports ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /driver/:id/reports/daily?date=YYYY-MM-DD
|
||||||
|
* Generate an HTML daily report for a driver on a specific date.
|
||||||
|
* Returns { reportUrl, filename }
|
||||||
|
* Requires: "generate-driver-report" permission
|
||||||
|
*/
|
||||||
|
getDailyReport: (id: string, date: string, token: string | null) =>
|
||||||
|
get<DriverReportResponse>(
|
||||||
|
`driver/${id}/reports/daily?date=${encodeURIComponent(date)}`,
|
||||||
|
token,
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Image upload (multipart) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /driver (multipart/form-data)
|
||||||
|
* Create a driver with photos attached.
|
||||||
|
* photo / nationalPhoto / driverCardPhoto are optional file fields.
|
||||||
|
*/
|
||||||
|
createWithImages: async (
|
||||||
|
payload: CreateDriverPayload & {
|
||||||
|
photo?: File;
|
||||||
|
nationalPhoto?: File;
|
||||||
|
driverCardPhoto?: File;
|
||||||
|
},
|
||||||
|
token: string | null,
|
||||||
|
): Promise<{ data: Driver }> => {
|
||||||
|
const form = new FormData();
|
||||||
|
|
||||||
|
// Text fields
|
||||||
|
Object.entries(payload).forEach(([key, val]) => {
|
||||||
|
if (
|
||||||
|
val !== undefined &&
|
||||||
|
val !== null &&
|
||||||
|
!(val instanceof File)
|
||||||
|
) {
|
||||||
|
form.append(key, String(val));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File fields
|
||||||
|
if (payload.photo) form.append("photo", payload.photo);
|
||||||
|
if (payload.nationalPhoto) form.append("nationalPhoto", payload.nationalPhoto);
|
||||||
|
if (payload.driverCardPhoto) form.append("driverCardPhoto", payload.driverCardPhoto);
|
||||||
|
|
||||||
|
const res = await fetch("/api/proxy/driver", {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: form,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ data: Driver }>;
|
||||||
|
},
|
||||||
|
};
|
||||||
141
types/client.ts
141
types/client.ts
@@ -1,24 +1,129 @@
|
|||||||
|
// ─── Domain Types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
taxId?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
addresses?: ClientAddress[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientAddress {
|
export interface ClientAddress {
|
||||||
_id: string;
|
id: string;
|
||||||
label: string;
|
clientId: string;
|
||||||
branchName: string | null;
|
label: string; // e.g. "Billing", "Shipping", "HQ"
|
||||||
contactPerson: { name: string; phone: string };
|
|
||||||
details: {
|
|
||||||
city: string;
|
|
||||||
district?: string;
|
|
||||||
street: string;
|
street: string;
|
||||||
buildingNo?: string;
|
city: string;
|
||||||
unitNo?: string;
|
state: string;
|
||||||
zipCode?: string;
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Request / Response shapes ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CreateClientPayload = Omit<Client, "id" | "createdAt" | "updatedAt" | "addresses">;
|
||||||
|
export type UpdateClientPayload = Partial<CreateClientPayload>;
|
||||||
|
|
||||||
|
export type CreateClientAddressPayload = Omit<
|
||||||
|
ClientAddress,
|
||||||
|
"id" | "createdAt" | "updatedAt"
|
||||||
|
>;
|
||||||
|
export type UpdateClientAddressPayload = Partial<CreateClientAddressPayload>;
|
||||||
|
|
||||||
|
// ─── Form & Validation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ClientFormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
taxId: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientFormErrors {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
taxId?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientAddressFormData {
|
||||||
|
label: string;
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientAddressFormErrors {
|
||||||
|
label?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Table State / Reducer (mirrors user.ts TableState / TableAction) ─────────
|
||||||
|
|
||||||
|
export type ClientTableState = {
|
||||||
|
clients: Client[];
|
||||||
|
loading: boolean;
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientTableAction =
|
||||||
|
| { type: "LOAD_START" }
|
||||||
|
| { type: "LOAD_OK"; clients: Client[]; total: number; pages: number }
|
||||||
|
| { type: "LOAD_ERR"; error: string }
|
||||||
|
| { type: "ADD"; client: Client }
|
||||||
|
| { type: "UPDATE"; client: Client }
|
||||||
|
| { type: "DELETE"; id: string }
|
||||||
|
| { type: "CLEAR_ERR" };
|
||||||
|
|
||||||
|
export type AddressTableState = {
|
||||||
|
addresses: ClientAddress[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddressTableAction =
|
||||||
|
| { type: "LOAD_START" }
|
||||||
|
| { type: "LOAD_OK"; addresses: ClientAddress[] }
|
||||||
|
| { type: "LOAD_ERR"; error: string }
|
||||||
|
| { type: "ADD"; address: ClientAddress }
|
||||||
|
| { type: "UPDATE"; address: ClientAddress }
|
||||||
|
| { type: "DELETE"; id: string }
|
||||||
|
| { type: "CLEAR_ERR" };
|
||||||
|
|
||||||
|
// ─── Generic API wrapper ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiListResponse<T> {
|
||||||
|
data: {
|
||||||
|
data: T[];
|
||||||
|
pagination: { total: number; page: number; pages: number };
|
||||||
|
meta?: { total: number; pages: number };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrderStatus = "CREATED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
|
export interface ApiError {
|
||||||
|
message: string;
|
||||||
export interface Order {
|
errors?: Record<string, string[]>;
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
status: OrderStatus;
|
|
||||||
totalAmount: number;
|
|
||||||
description: string;
|
|
||||||
}
|
}
|
||||||
124
types/driver.ts
Normal file
124
types/driver.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
export type DriverStatus = "Active" | "Inactive" | "InTrip" | "Suspended";
|
||||||
|
export type NationalIdType = "NationalID" | "Iqama" | "Passport";
|
||||||
|
export type DriverCardType = "Temporary" | "Seasonal" | "Annual" | "Restricted";
|
||||||
|
|
||||||
|
// ── Core Driver ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Driver {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
userName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
nationality?: string | null;
|
||||||
|
nationalId?: string | null;
|
||||||
|
nationalIdType?: NationalIdType | null;
|
||||||
|
nationalIdExpiry?: string | null;
|
||||||
|
gosiNumber?: string | null;
|
||||||
|
phone: string;
|
||||||
|
licenseNumber?: string | null;
|
||||||
|
licenseType?: string | null;
|
||||||
|
licenseExpiry?: string | null;
|
||||||
|
photo?: string | null;
|
||||||
|
nationalPhoto?: string | null;
|
||||||
|
driverCardPhoto?: string | null;
|
||||||
|
driverCardNumber?: string | null;
|
||||||
|
driverCardType?: DriverCardType | null;
|
||||||
|
driverCardExpiry?: string | null;
|
||||||
|
driverType?: string | null;
|
||||||
|
status: DriverStatus;
|
||||||
|
branchId?: string | null;
|
||||||
|
branch?: { name: string } | null;
|
||||||
|
statusHistory?: DriverStatusHistoryEntry[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverStatusHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
status: DriverStatus;
|
||||||
|
reason?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payloads ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateDriverPayload {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
address?: string;
|
||||||
|
nationality?: string;
|
||||||
|
nationalIdType?: NationalIdType;
|
||||||
|
gosiNumber?: string;
|
||||||
|
licenseNumber?: string;
|
||||||
|
licenseType?: string;
|
||||||
|
licenseExpiry?: string;
|
||||||
|
driverCardNumber?: string;
|
||||||
|
driverCardType?: DriverCardType;
|
||||||
|
driverCardExpiry?: string;
|
||||||
|
driverType?: string;
|
||||||
|
branchId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateDriverPayload = Partial<CreateDriverPayload> & {
|
||||||
|
status?: DriverStatus;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── API Responses ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DriverListResponse {
|
||||||
|
data: {
|
||||||
|
data: Driver[];
|
||||||
|
pagination: { total: number; page: number; pages: number };
|
||||||
|
meta?: { total: number; pages: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverDetailResponse {
|
||||||
|
data: Driver;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverReportResponse {
|
||||||
|
data: {
|
||||||
|
reportUrl: string;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DriverFormErrors {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
licenseNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DRIVER_STATUS_MAP: Record<
|
||||||
|
DriverStatus,
|
||||||
|
{ label: string; color: string; bg: string; border: string; dot: string }
|
||||||
|
> = {
|
||||||
|
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
|
||||||
|
InTrip: { label: "في رحلة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
|
||||||
|
Inactive: { label: "غير نشط", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0", dot: "#94A3B8" },
|
||||||
|
Suspended: { label: "موقوف", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NATIONAL_ID_TYPE_MAP: Record<NationalIdType, string> = {
|
||||||
|
NationalID: "هوية وطنية",
|
||||||
|
Iqama: "إقامة",
|
||||||
|
Passport: "جواز سفر",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DRIVER_CARD_TYPE_MAP: Record<DriverCardType, string> = {
|
||||||
|
Temporary: "مؤقتة",
|
||||||
|
Seasonal: "موسمية",
|
||||||
|
Annual: "سنوية",
|
||||||
|
Restricted: "مقيدة",
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user