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 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";
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
userName?: string;
|
||||
phone: string;
|
||||
nationality?: string;
|
||||
licenseNumber?: string;
|
||||
licenseExpiry?: string;
|
||||
nationalIdExpiry?: string;
|
||||
status: "Active" | "Inactive" | "InTrip" | "Suspended";
|
||||
isActive: boolean;
|
||||
branch?: { name: string };
|
||||
createdAt: string;
|
||||
}
|
||||
import { useState, useCallback } from "react";
|
||||
import { Alert, Spinner } from "@/Components/UI";
|
||||
import { DriverFormModal } from "@/Components/Driver/DriverFormModal";
|
||||
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal";
|
||||
import { driverService } from "@/services";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import { useDrivers } from "@/hooks/useDriver";
|
||||
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/types/driver";
|
||||
import { DriverDetailPanel } from "@/Components/Driver/DriverDetailPanel";
|
||||
|
||||
interface ApiResponse {
|
||||
data: {
|
||||
data: Driver[];
|
||||
pagination: { total: number; page: number; pages: number };
|
||||
meta?: { total: number; pages: number };
|
||||
};
|
||||
}
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_MAP: Record<Driver["status"], { label: string; color: string; bg: string; border: 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) {
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
year: "numeric", month: "short", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function expirySoon(iso?: string) {
|
||||
function expirySoon(iso?: string | null): boolean {
|
||||
if (!iso) return false;
|
||||
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
@@ -66,35 +46,81 @@ const thStyle: React.CSSProperties = {
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
};
|
||||
|
||||
// ── Page Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DriversPage() {
|
||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pages, setPages] = useState(1);
|
||||
const {
|
||||
drivers, loading, error, total, pages, page,
|
||||
search, setPage, handleSearch, clearError,
|
||||
deleteDriver, notification, reload,
|
||||
} = useDrivers();
|
||||
|
||||
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();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
get<ApiResponse>(`driver${query}`, token)
|
||||
.then((res) => {
|
||||
const payload = res.data ?? res;
|
||||
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 (isNew) {
|
||||
// Use multipart if there are file fields
|
||||
const hasFiles =
|
||||
(payload as CreateDriverPayload & { photo?: File }).photo ||
|
||||
(payload as CreateDriverPayload & { nationalPhoto?: File }).nationalPhoto ||
|
||||
(payload as CreateDriverPayload & { driverCardPhoto?: File }).driverCardPhoto;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* Header */}
|
||||
{/* ── Header ── */}
|
||||
<header style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
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>
|
||||
<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>
|
||||
<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> سائق مسجل
|
||||
إجمالي{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "}
|
||||
سائق مسجل
|
||||
</p>
|
||||
</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" }}
|
||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
{/* 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" />
|
||||
</svg>
|
||||
<input type="text" placeholder="بحث بالاسم أو الهاتف..." value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }} dir="rtl"
|
||||
<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)",
|
||||
@@ -131,24 +169,60 @@ export default function DriversPage() {
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</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 dir="rtl" style={{
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.2fr 1fr 1fr 1fr 1fr",
|
||||
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
|
||||
...thStyle,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<span>السائق</span>
|
||||
<span>الفرع</span>
|
||||
<span>الجنسية</span>
|
||||
<span>انتهاء الرخصة</span>
|
||||
<span>انتهاء الهوية</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>انتهاء الهوية</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -163,47 +237,62 @@ export default function DriversPage() {
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{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 idWarn = expirySoon(d.nationalIdExpiry);
|
||||
const idWarn = expirySoon((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry);
|
||||
|
||||
return (
|
||||
<li key={d.id} style={{
|
||||
<li
|
||||
key={d.id}
|
||||
onClick={() => setSelectedDriverId(d.id)}
|
||||
style={{
|
||||
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",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Branch */}
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
|
||||
|
||||
{/* Nationality */}
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span>
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 600,
|
||||
color: licWarn ? "#D97706" : "var(--color-text-secondary)",
|
||||
}}>
|
||||
|
||||
{/* License expiry */}
|
||||
<span style={{ fontSize: 12, fontWeight: licWarn ? 600 : 400, color: licWarn ? "#D97706" : "var(--color-text-secondary)" }}>
|
||||
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
|
||||
</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={{
|
||||
display: "inline-flex", alignItems: "center",
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${status.border}`,
|
||||
background: status.bg,
|
||||
border: `1px solid ${statusCfg.border}`,
|
||||
background: statusCfg.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11, fontWeight: 600, color: status.color,
|
||||
fontSize: 11, fontWeight: 600, color: statusCfg.color,
|
||||
width: "fit-content",
|
||||
}}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span style={{
|
||||
textAlign: "center", fontSize: 12, fontWeight: 600,
|
||||
color: idWarn ? "#D97706" : "var(--color-text-secondary)",
|
||||
}}>
|
||||
{idWarn && "⚠ "}{fmtDate(d.nationalIdExpiry)}
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
@@ -211,20 +300,30 @@ export default function DriversPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{pages > 1 && (
|
||||
<div dir="rtl" style={{
|
||||
<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>
|
||||
صفحة{" "}
|
||||
<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: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={btn.action}
|
||||
disabled={btn.disabled}
|
||||
style={{
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
@@ -234,7 +333,8 @@ export default function DriversPage() {
|
||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
||||
opacity: btn.disabled ? 0.4 : 1,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -243,5 +343,36 @@ export default function DriversPage() {
|
||||
)}
|
||||
</div>
|
||||
</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";
|
||||
|
||||
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 CreateClientPayload {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName?: string;
|
||||
/** نوع الاستجابة لعملية واحدة على عميل */
|
||||
export interface ClientResponse {
|
||||
data: Client;
|
||||
}
|
||||
|
||||
export interface CreateAddressPayload {
|
||||
label: string;
|
||||
branchName?: string | null;
|
||||
contactPerson: { name: string; phone: string };
|
||||
details: {
|
||||
city: string;
|
||||
district?: string;
|
||||
street: string;
|
||||
buildingNo?: string;
|
||||
unitNo?: string;
|
||||
zipCode?: string;
|
||||
};
|
||||
/** بناء query string لجلب العملاء مع البحث والصفحات */
|
||||
function buildClientsQuery(page: number, search: string): string {
|
||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
}
|
||||
|
||||
export const clientService = {
|
||||
/** Register a new client account */
|
||||
create: (payload: CreateClientPayload) =>
|
||||
post<{ id: string; name: string; email: string; phone: string }>(
|
||||
"client",
|
||||
payload,
|
||||
),
|
||||
/** جلب قائمة العملاء مع إمكانية البحث والتصفح بين الصفحات */
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ApiListResponse<Client>>(`client${buildClientsQuery(page, search)}`, token),
|
||||
|
||||
/** Fetch all saved delivery addresses for a client */
|
||||
getAddresses: (clientId: string) =>
|
||||
get<ClientAddress[]>(`client/${clientId}/addresses`),
|
||||
/** جلب عميل واحد بالـ ID (مع العناوين المرفقة) */
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<ApiResponse<Client>>(`client/${id}`, token),
|
||||
|
||||
/** Save a new delivery address */
|
||||
addAddress: (clientId: string, payload: CreateAddressPayload) =>
|
||||
post<ClientAddress>(`client/${clientId}/addresses`, payload),
|
||||
/** إنشاء عميل جديد */
|
||||
create: (data: ClientFormData, token: string | null) =>
|
||||
post<ClientResponse>("client", buildPayload(data), token),
|
||||
|
||||
/** Fetch all orders placed by a client */
|
||||
getOrders: (clientId: string) =>
|
||||
get<Order[]>(`client/${clientId}/orders`),
|
||||
/** تعديل بيانات عميل موجود */
|
||||
update: (id: string, data: ClientFormData, token: string | null) =>
|
||||
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 {
|
||||
_id: string;
|
||||
label: string;
|
||||
branchName: string | null;
|
||||
contactPerson: { name: string; phone: string };
|
||||
details: {
|
||||
city: string;
|
||||
district?: string;
|
||||
id: string;
|
||||
clientId: string;
|
||||
label: string; // e.g. "Billing", "Shipping", "HQ"
|
||||
street: string;
|
||||
buildingNo?: string;
|
||||
unitNo?: string;
|
||||
zipCode?: string;
|
||||
city: string;
|
||||
state: 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 Order {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
status: OrderStatus;
|
||||
totalAmount: number;
|
||||
description: string;
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
errors?: Record<string, 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