first action update user pages .

2-update driver pages [fixed image display and notyfi]
3-update car pages [fixed image]
4-build tripe pages crud
5-build some role pages
6-build audit page
7-update ui compounants
8-update saidebar and topbar
9-cheange view to be ar view
10-cheange stractcher to be app,src
11-add validation layer by yup
12-add api image-proxy to broke image corse bloken
This commit is contained in:
m7amedez5511
2026-06-23 15:54:07 +03:00
parent c1b77f89cd
commit db64b79fe3
140 changed files with 9266 additions and 2449 deletions

View File

View File

@@ -0,0 +1,392 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Alert, Spinner } from "../UI";
import type {
ClientAddress,
ClientAddressFormData,
ClientAddressFormErrors,
} from "@/src/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>
);
}

View File

@@ -0,0 +1,175 @@
import React, { useState, FormEvent } from "react";
import { ClientAddress, CreateClientAddressPayload } from "@/src/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>
);
}

View File

@@ -0,0 +1,135 @@
import React, { useState, FormEvent } from "react";
import { Client, CreateClientPayload } from "@/src/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>
);
}

View File

@@ -0,0 +1,353 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Alert, Spinner } from "../UI";
import type { Client, ClientFormData, ClientFormErrors } from "@/src/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 = "رقم هاتف غير صالح (1015 رقم)";
}
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>
);
}

View File

@@ -0,0 +1,403 @@
"use client";
import { Spinner } from "../UI";
import type { Client } from "@/src/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>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Client } from "@/src/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>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useState } from "react";
import type { Notification } from "@/src/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>
);
}

View File

@@ -0,0 +1,5 @@
export { ClientFormModal } from "./Clientformmodal";
export { ClientTable } from "./Clienttable";
export { AddressFormModal } from "./Addressformmodal";
export { DeleteConfirmModal } from "./Deleteconfirmmodal";
export { Toast } from "./Toast";

View File

@@ -0,0 +1,100 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Driver } from "@/src/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>
);
}

View File

@@ -0,0 +1,565 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Spinner } from "../UI";
import { driverService } from "@/src/services/driver.service";
import { getStoredToken } from "@/src/lib/auth";
import { DriverReportPanel } from "../Driver_Report/driverReport";
import type { Driver } from "@/src/types/driver";
import {
DRIVER_STATUS_MAP,
DRIVER_CARD_TYPE_MAP,
NATIONAL_ID_TYPE_MAP,
} from "@/src/types/driver";
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function isExpiringSoon(iso?: string | null): boolean {
if (!iso) return false;
return new Date(iso).getTime() - Date.now() <= 90 * 86_400_000;
}
// ── 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>
);
}
// ── PhotoCard ─────────────────────────────────────────────────────────────────
// key={url} on the <img> forces a full remount whenever the URL changes,
// which clears any stale onError state from a previously failed load.
function PhotoCard({ url, label }: { url?: string | null; label: string }) {
const [imgError, setImgError] = useState(false);
useEffect(() => {
setImgError(false);
}, [url]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)" }}>
{label}
</span>
<div
style={{
width: "100%",
aspectRatio: "4/3",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{url && !imgError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={url}
src={url}
alt={label}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setImgError(true)}
/>
) : (
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)}
</div>
{url && !imgError && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 11,
color: "#2563EB",
textDecoration: "underline",
textAlign: "center",
}}
>
عرض الصورة
</a>
)}
</div>
);
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface DriverDetailPanelProps {
driverId: string;
onClose: () => void;
onEdit: (driver: Driver) => void;
onDelete: (driver: Driver) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function DriverDetailPanel({
driverId,
onClose,
onEdit,
onDelete,
}: DriverDetailPanelProps) {
const [driver, setDriver] = useState<Driver | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
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 */}
<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 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Avatar — key={photoUrl} forces remount on photo change */}
<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",
}}
>
{driver?.photoUrl ? (
<AvatarImg src={driver.photoUrl} name={driver.name} />
) : (
<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>
<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 && (
<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 && (
<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>
<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 ?? "—"} />
<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 />
<SectionHeading title="بيانات رخصة القيادة" />
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono />
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
<DetailRow
label="انتهاء الرخصة"
value={fmtDate(driver.licenseExpiry)}
warn={isExpiringSoon(driver.licenseExpiry)}
/>
<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 ?? "—"} />
<SectionHeading title="معلومات النظام" />
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono />
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
<SectionHeading title="الصور والمستندات" />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem", marginTop: "0.5rem" }}>
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة البطاقة" />
</div>
{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>
</>
)}
<DriverReportPanel 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,
}}
>
<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>
<button
type="button"
onClick={() => onEdit(driver)}
style={{
height: 40, padding: "0 1rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-brand-200)",
background: "var(--color-brand-50, #EFF6FF)",
fontSize: 13, fontWeight: 700, color: "var(--color-brand-600)",
cursor: "pointer", fontFamily: "var(--font-sans)",
display: "flex", alignItems: "center", gap: 6,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<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>
تعديل
</button>
<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>
</>
);
}
// ── AvatarImg ─────────────────────────────────────────────────────────────────
// Separate component so key={src} forces a full remount on photo change,
// avoiding stale onError state from a previously failed load.
function AvatarImg({ src, name }: { src: string; name: string }) {
const [error, setError] = useState(false);
if (error) {
return (
<span style={{ fontSize: 20, fontWeight: 700, color: "var(--color-brand-600)" }}>
{name.charAt(0)}
</span>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
key={src}
src={src}
alt={name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setError(true)}
/>
);
}

View File

@@ -0,0 +1,693 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as yup from "yup";
import { Alert, Spinner } from "../UI";
import { get } from "@/src/services/api";
import { getStoredToken } from "@/src/lib/auth";
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator";
import type { DriverSchemaErrors } from "@/src/validations/driver.validator";
import type {
Driver,
CreateDriverPayload,
UpdateDriverPayload,
NationalIdType,
DriverCardType,
DriverStatus,
} from "@/src/types/driver";
import type { Branch } from "@/src/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",
};
const optionalLabelStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
marginRight: 4,
};
// ── Date helper ──────────────────────────────────────────────────────────────
function toIsoDateTime(val: string): string {
if (!val) return val;
if (val.includes("T")) return val;
return `${val}T00:00:00.000Z`;
}
// ── 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(
(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<DriverSchemaErrors>({});
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 DriverSchemaErrors): React.CSSProperties => ({
...inputBase,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
const clearFieldError = (field: keyof DriverSchemaErrors) =>
setErrors((p) => ({ ...p, [field]: undefined }));
// ── Submit with yup validation ────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Build the raw object to validate
const raw: Record<string, unknown> = {
name: name || undefined,
phone: phone || undefined,
email: email || undefined,
address: address || undefined,
nationality: nationality || undefined,
nationalIdType: nationalIdType || undefined,
gosiNumber: gosiNumber || undefined,
licenseNumber: licenseNumber || undefined,
licenseType: licenseType || undefined,
licenseExpiry: licenseExpiry || undefined,
driverCardNumber: driverCardNumber || undefined,
driverCardType: driverCardType || undefined,
driverCardExpiry: driverCardExpiry || undefined,
driverType: driverType || undefined,
branchId: branchId || undefined,
...(!isNew ? { status } : {}),
};
try {
const schema = isNew ? createDriverSchema : updateDriverSchema;
await schema.validate(raw, { abortEarly: false });
} catch (err) {
if (err instanceof yup.ValidationError) {
const fieldErrors: DriverSchemaErrors = {};
err.inner.forEach((e) => {
if (e.path) {
fieldErrors[e.path as keyof DriverSchemaErrors] = e.message;
}
});
setErrors(fieldErrors);
return;
}
}
// ── Build clean payload to send to backend ────────────────────────────
const payload: Record<string, unknown> = { name, phone };
if (email) payload.email = email;
if (address) payload.address = address;
if (nationality) payload.nationality = nationality;
if (nationalIdType) payload.nationalIdType = nationalIdType;
if (nationalId) payload.nationalId = nationalId;
if (nationalIdExpiry) payload.nationalIdExpiry = toIsoDateTime(nationalIdExpiry);
if (gosiNumber) payload.gosiNumber = gosiNumber;
if (licenseNumber) payload.licenseNumber = licenseNumber;
if (licenseType) payload.licenseType = licenseType;
if (licenseExpiry) payload.licenseExpiry = toIsoDateTime(licenseExpiry);
if (driverCardNumber) payload.driverCardNumber = driverCardNumber;
if (driverCardType) payload.driverCardType = driverCardType;
if (driverCardExpiry) payload.driverCardExpiry = toIsoDateTime(driverCardExpiry);
if (driverType) payload.driverType = driverType;
if (branchId) payload.branchId = branchId;
if (!isNew) payload.status = status;
if (photo) payload.photo = photo;
if (nationalPhoto) payload.nationalPhoto = nationalPhoto;
if (driverCardPhoto) payload.driverCardPhoto = driverCardPhoto;
setSaving(true);
setApiError("");
try {
const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew);
if (ok) {
onClose();
} else {
// onSubmit returned false without throwing — show generic fallback
setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}
} catch (err) {
// onSubmit threw — surface the real API error message
const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
setApiError(message);
} finally {
setSaving(false);
}
};
// ── 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" }}>
{/* Name — required */}
<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>
{/* Phone — required */}
<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>
{/* Email — optional */}
<label style={labelStyle}>
البريد الإلكتروني
<span style={optionalLabelStyle}>(اختياري)</span>
<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>
{/* Nationality — optional */}
<label style={labelStyle}>
الجنسية
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputBase}
value={nationality}
onChange={(e) => { setNationality(e.target.value); clearFieldError("nationality"); }}
placeholder="سعودي"
dir="rtl"
/>
{errors.nationality && <span style={errorTextStyle}>{errors.nationality}</span>}
</label>
{/* Address — optional, full width */}
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
العنوان
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputStyle("address")}
value={address}
onChange={(e) => { setAddress(e.target.value); clearFieldError("address"); }}
placeholder="الرياض، حي..."
dir="rtl"
/>
{errors.address && <span style={errorTextStyle}>{errors.address}</span>}
</label>
</div>
{/* ── Section: ID & GOSI ── */}
<p style={sectionHeadingStyle}>الهوية والتأمينات</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* National ID Type — optional */}
<label style={labelStyle}>
نوع الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...inputStyle("nationalIdType"), cursor: "pointer" }}
value={nationalIdType}
onChange={(e) => { setNationalIdType(e.target.value as NationalIdType | ""); clearFieldError("nationalIdType"); }}
dir="rtl"
>
<option value="">اختر النوع</option>
<option value="NationalID">هوية وطنية</option>
<option value="Iqama">إقامة</option>
<option value="Passport">جواز سفر</option>
</select>
{errors.nationalIdType && <span style={errorTextStyle}>{errors.nationalIdType}</span>}
</label>
{/* National ID number */}
<label style={labelStyle}>
رقم الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputBase}
value={nationalId}
onChange={(e) => setNationalId(e.target.value)}
placeholder="1XXXXXXXXX"
dir="ltr"
/>
</label>
{/* National ID expiry */}
<label style={labelStyle}>
انتهاء الهوية
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputBase}
type="date"
value={nationalIdExpiry}
onChange={(e) => setNationalIdExpiry(e.target.value)}
/>
</label>
{/* GOSI — optional */}
<label style={labelStyle}>
رقم GOSI
<span style={optionalLabelStyle}>(اختياري)</span>
<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" }}>
{/* License number — optional */}
<label style={labelStyle}>
رقم الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<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>
{/* License type — optional */}
<label style={labelStyle}>
نوع الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputBase}
value={licenseType}
onChange={(e) => setLicenseType(e.target.value)}
placeholder="خاص / عام"
dir="rtl"
/>
</label>
{/* License expiry — optional, validated if provided */}
<label style={labelStyle}>
انتهاء الرخصة
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputStyle("licenseExpiry")}
type="date"
value={licenseExpiry}
onChange={(e) => { setLicenseExpiry(e.target.value); clearFieldError("licenseExpiry"); }}
/>
{errors.licenseExpiry && <span style={errorTextStyle}>{errors.licenseExpiry}</span>}
</label>
</div>
{/* ── Section: Driver Card ── */}
<p style={sectionHeadingStyle}>بطاقة السائق</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* Card number — optional */}
<label style={labelStyle}>
رقم البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputBase}
value={driverCardNumber}
onChange={(e) => setDriverCardNumber(e.target.value)}
placeholder="CARD-XXXX"
dir="ltr"
/>
</label>
{/* Card type — optional */}
<label style={labelStyle}>
نوع البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...inputStyle("driverCardType"), cursor: "pointer" }}
value={driverCardType}
onChange={(e) => { setDriverCardType(e.target.value as DriverCardType | ""); clearFieldError("driverCardType"); }}
dir="rtl"
>
<option value="">اختر النوع</option>
<option value="Temporary">مؤقتة</option>
<option value="Seasonal">موسمية</option>
<option value="Annual">سنوية</option>
<option value="Restricted">مقيدة</option>
</select>
{errors.driverCardType && <span style={errorTextStyle}>{errors.driverCardType}</span>}
</label>
{/* Card expiry — optional, validated if provided */}
<label style={labelStyle}>
انتهاء البطاقة
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputStyle("driverCardExpiry")}
type="date"
value={driverCardExpiry}
onChange={(e) => { setDriverCardExpiry(e.target.value); clearFieldError("driverCardExpiry"); }}
/>
{errors.driverCardExpiry && <span style={errorTextStyle}>{errors.driverCardExpiry}</span>}
</label>
</div>
{/* ── Section: Operational ── */}
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
{/* Branch — optional */}
<label style={labelStyle}>
الفرع
<span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...inputStyle("branchId"), cursor: "pointer" }}
value={branchId}
onChange={(e) => { setBranchId(e.target.value); clearFieldError("branchId"); }}
dir="rtl"
>
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
{errors.branchId && <span style={errorTextStyle}>{errors.branchId}</span>}
</label>
{/* Driver type — optional */}
<label style={labelStyle}>
نوع السائق
<span style={optionalLabelStyle}>(اختياري)</span>
<input
style={inputBase}
value={driverType}
onChange={(e) => setDriverType(e.target.value)}
placeholder="رئيسي / احتياطي"
dir="rtl"
/>
</label>
{/* Status — edit only */}
{!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>
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}>
جميع حقول الصور اختيارية
</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>
);
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
export function PhotoCard({
url,
label,
}: {
url?: string | null;
label: string;
}) {
const [imgError, setImgError] = useState(false);
// Reset error state when url changes so updated images get a fresh load attempt
useEffect(() => {
setImgError(false);
}, [url]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
}}
>
{label}
</span>
<div
style={{
width: "100%",
aspectRatio: "4/3",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{url && !imgError ? (
// key={url} forces a full remount whenever the URL changes,
// clearing any stale error state from a previous failed load.
// eslint-disable-next-line @next/next/no-img-element
<img
key={url}
src={url}
alt={label}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setImgError(true)}
/>
) : (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)}
</div>
{url && !imgError && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 11,
color: "#2563EB",
textDecoration: "underline",
textAlign: "center",
}}
>
عرض الصورة
</a>
)}
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { DriverFormModal } from "./DriverFormModal";
export { DriverDeleteModal } from "./DriverDeleteModal";
export { DriverDetailPanel } from "./DriverDetailPanel";
export { PhotoCard } from "./DriverPhotos";

View File

@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import { Spinner } from "../UI";
import { driverService } from "@/src/services/driver.service";
import { getStoredToken } from "@/src/lib/auth";
// ── Props ─────────────────────────────────────────────────────────────────────
interface DriverReportPanelProps {
driverId: string;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
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 handleGenerate = async () => {
if (!date) return;
setLoading(true);
setError(null);
try {
const token = getStoredToken();
const res = await driverService.getDailyReport(driverId, date, token);
// The response shape: { data: { reportUrl, filename } }
const reportUrl = (
res as unknown as { data: { reportUrl: string; filename: string } }
).data?.reportUrl;
if (!reportUrl) throw new Error("لم يتم إرجاع رابط التقرير.");
// Navigate directly to the report URL
window.location.href = reportUrl;
} catch (err) {
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",
}}
>
{/* Heading */}
<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: "flex-end",
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);
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={{
height: 38,
padding: "0 1rem",
borderRadius: "var(--radius-md)",
border: "none",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
background: !date || loading
? "var(--color-brand-400)"
: "var(--color-brand-600)",
cursor: !date || loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
whiteSpace: "nowrap",
}}
>
{loading && <Spinner size="sm" className="text-white" />}
{loading ? "جارٍ الإنشاء…" : "إنشاء التقرير"}
</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>
)}
</div>
);
}

View File

View File

@@ -0,0 +1,210 @@
"use client";
import React, { type FC } from "react";
import { useClientOrders } from "@/src/hooks/useClientOrders";
import { fmtDate, fmtAmount } from "@/src/lib/formatters";
import { statusColor, statusLabel } from "@/src/lib/order-status";
import { clearSession, type ClientSession } from "@/src/lib/session";
import type { OrderStatus } from "@/src/types/order";
import { Spinner } from "../UI";
// ─── Order status tracker ─────────────────────
const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"];
const STEP_LABELS = ["تم الإنشاء", "قيد التوصيل", "تم التسليم"];
const StepIcon: FC<{ step: number }> = ({ step }) => {
if (step === 0) return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M4 7h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/>
</svg>
);
if (step === 1) return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="7" width="20" height="14" rx="2"/>
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
<line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/>
</svg>
);
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
};
const OrderTracker: FC<{ status: OrderStatus }> = ({ status }) => {
const idx = STATUS_STEPS.indexOf(status === "CANCELLED" ? "CREATED" : status);
return (
<div className="flex items-center gap-0 py-2" dir="rtl" role="list" aria-label="تقدم الطلب">
{STATUS_STEPS.map((s, i) => (
<React.Fragment key={s}>
<div className="flex flex-col items-center gap-1" role="listitem">
<div className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
status === "CANCELLED"
? "bg-[#FEE2E2] text-red-500 border-2 border-red-200"
: i <= idx ? "bg-[#1A73E8] text-white" : "bg-white border-2 border-[#E5E7EB] text-[#D1D5DB]"
}`} aria-current={i === idx ? "step" : undefined}>
<StepIcon step={i} />
</div>
<span className={`text-[10px] font-semibold whitespace-nowrap ${
status === "CANCELLED" ? "text-red-400" : i <= idx ? "text-[#1A73E8]" : "text-[#9CA3AF]"
}`}>
{STEP_LABELS[i]}
</span>
</div>
{i < STATUS_STEPS.length - 1 && (
<div aria-hidden="true" className={`flex-1 h-0.5 mb-4 mx-1 ${
status === "CANCELLED" ? "bg-red-200" : i < idx ? "bg-[#1A73E8]" : "bg-[#E5E7EB]"
}`} />
)}
</React.Fragment>
))}
</div>
);
};
// ─── DashboardStep ───────────────────────────
interface DashboardStepProps {
session: ClientSession;
}
const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
const { orders, loading, error } = useClientOrders(session.id);
const activeOrder = orders.find(o => o.status === "CREATED" || o.status === "IN_TRANSIT");
const pastOrders = orders.filter(o => o !== activeOrder);
const summary = {
total: orders.length,
delivered: orders.filter(o => o.status === "DELIVERED").length,
pending: orders.filter(o => o.status === "CREATED" || o.status === "IN_TRANSIT").length,
};
return (
<div dir="rtl">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight">لوحة الطلبات</h2>
<p className="text-[13px] text-[#6B7280] mt-0.5">
مرحبًا بعودتك، <strong className="text-[#111827]">{session.name}</strong>
</p>
</div>
<div className="flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1.5" role="status" aria-label="الحساب نشط">
<span aria-hidden="true" className="w-2 h-2 rounded-full bg-[#10B981] motion-safe:animate-pulse" />
<span className="text-[11px] font-bold text-[#065F46]">نشط</span>
</div>
</div>
{/* Loading */}
{loading && (
<div className="flex flex-col items-center justify-center py-14 gap-3" role="status">
<Spinner />
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل الطلبات</p>
</div>
)}
{/* Error */}
{error && (
<div className="flex flex-col items-center gap-3 py-10" role="alert">
<p className="text-[13px] text-red-500">{error}</p>
</div>
)}
{!loading && !error && (
<>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 mb-6">
{[
{ label: "إجمالي الطلبات", value: summary.total, color: "text-[#111827]" },
{ label: "مُسلَّمة", value: summary.delivered, color: "text-[#34A853]" },
{ label: "قيد التنفيذ", value: summary.pending, color: "text-[#1A73E8]" },
].map(s => (
<div key={s.label} className="bg-white border border-[#E5E7EB] rounded-xl p-3.5">
<p className="text-[10px] font-bold text-[#9CA3AF] mb-1">{s.label}</p>
<p className={`text-[26px] font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Active order tracker */}
{activeOrder ? (
<div className="bg-gradient-to-bl from-[#0D47A1] to-[#1A73E8] rounded-xl p-4 mb-6">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-[11px] font-bold text-white/60 tracking-wide">الطلب الحالي</p>
<p className="text-[14px] font-bold text-white font-mono">{activeOrder.id}</p>
</div>
<span className={`text-[11px] font-bold border px-2.5 py-1 rounded-full ${
activeOrder.status === "CREATED"
? "bg-white/15 border-white/30 text-white"
: "bg-[#D1FAE5] border-[#A7F3D0] text-[#065F46]"
}`}>
{statusLabel(activeOrder.status)}
</span>
</div>
<OrderTracker status={activeOrder.status} />
</div>
) : (
<div className="border border-dashed border-[#E5E7EB] rounded-xl p-5 mb-6 text-center">
<p className="text-[13px] text-[#9CA3AF]">لا يوجد طلب نشط حاليًا.</p>
</div>
)}
{/* Past orders table */}
{pastOrders.length > 0 && (
<div className="bg-white border border-[#E5E7EB] rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-[#E5E7EB] flex items-center justify-between">
<p className="text-[13px] font-bold text-[#111827]">سجل الطلبات</p>
<span className="text-[11px] text-[#9CA3AF] font-medium">{pastOrders.length} طلبات</span>
</div>
<div className="overflow-x-auto">
<table className="w-full" dir="rtl">
<thead>
<tr className="bg-[#F9FAFB]">
{["رقم الطلب", "التاريخ", "الوصف", "الحالة", "المبلغ"].map(h => (
<th key={h} scope="col" className="px-4 py-2.5 text-right text-[10px] font-bold text-[#9CA3AF] uppercase tracking-wide">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{pastOrders.map((order, i) => (
<tr key={order.id} className={`border-t border-[#F3F4F6] hover:bg-[#FAFAFA] transition-colors ${i % 2 === 0 ? "" : "bg-[#FAFAFA]/30"}`}>
<td className="px-4 py-3">
<span className="font-mono text-[12px] font-semibold text-[#1A73E8]">{order.id}</span>
</td>
<td className="px-4 py-3 text-[12px] text-[#6B7280] whitespace-nowrap">{fmtDate(order.createdAt)}</td>
<td className="px-4 py-3 text-[12px] text-[#374151]">{order.description}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 text-[11px] font-bold border px-2.5 py-0.5 rounded-full whitespace-nowrap ${statusColor(order.status)}`}>
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-current" />
{statusLabel(order.status)}
</span>
</td>
<td className="px-4 py-3 text-[12px] font-semibold text-[#111827] font-mono whitespace-nowrap">
{fmtAmount(order.totalAmount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
<button
type="button"
onClick={() => { clearSession(); window.location.reload(); }}
className="mt-4 text-[11px] text-[#9CA3AF] hover:text-red-400 underline transition-colors"
>
مسح الجلسة وإعادة البدء
</button>
</div>
);
};
export default DashboardStep;

View File

@@ -0,0 +1,99 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Trip } from "@/src/types/trip";
interface TripDeleteModalProps {
trip: Trip;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function TripDeleteModal({ trip, deleting, onCancel, onConfirm }: TripDeleteModalProps) {
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="trip-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="trip-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)" }}>{trip.title}</strong>
{" "}(
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
{trip.tripNumber}
</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>
);
}

View File

@@ -0,0 +1,732 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as yup from "yup";
import { Spinner } from "../UI";
import { get } from "@/src/services/api";
import { getStoredToken } from "@/src/lib/auth";
import {
createTripSchema,
updateTripSchema,
} from "@/src/validations/trip.validator";
import type { TripSchemaErrors } from "@/src/validations/trip.validator";
import type {
Trip,
TripStatus,
CreateTripPayload,
UpdateTripPayload,
} from "@/src/types/trip";
// ── Shared styles ────────────────────────────────────────────────────────────
const inputBase: React.CSSProperties = {
width: "100%",
height: 40,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
};
const labelStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
};
const errorTextStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--color-danger)",
fontWeight: 500,
};
const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: "0.5rem 0 0",
};
const optionalLabelStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
marginRight: 4,
};
// ── Driver / Car / Branch minimal types ─────────────────────────────────────
interface DriverOption {
id: string;
name: string;
phone: string;
status: string;
}
interface CarOption {
id: string;
manufacturer: string;
model: string;
plateNumber: string;
status?: string;
}
interface BranchOption {
id: string;
name: string;
}
// ── Props ────────────────────────────────────────────────────────────────────
interface TripFormModalProps {
editTrip: Trip | null;
onClose: () => void;
onSubmit: (
payload: CreateTripPayload | UpdateTripPayload,
isNew: boolean,
) => Promise<boolean>;
}
// ── Component ────────────────────────────────────────────────────────────────
export function TripFormModal({
editTrip,
onClose,
onSubmit,
}: TripFormModalProps) {
const isNew = editTrip === null;
// ── Dropdown options ──────────────────────────────────────────────────────
const [drivers, setDrivers] = useState<DriverOption[]>([]);
const [cars, setCars] = useState<CarOption[]>([]);
const [branches, setBranches] = useState<BranchOption[]>([]);
useEffect(() => {
const token = getStoredToken();
get<{ data: { data: DriverOption[] } }>(
"driver?limit=100&status=Active",
token,
)
.then((res) =>
setDrivers(
(res as unknown as { data: { data: DriverOption[] } }).data?.data ??
[],
),
)
.catch(() => {});
get<{ data: { data: CarOption[] } }>("cars?limit=100&status=Active", token)
.then((res) =>
setCars(
(res as unknown as { data: { data: CarOption[] } }).data?.data ?? [],
),
)
.catch(() => {});
get<{ data: { data: BranchOption[] } }>("branches?limit=100", token)
.then((res) =>
setBranches(
(res as unknown as { data: { data: BranchOption[] } }).data?.data ??
[],
),
)
.catch(() => {});
}, []);
// ── Form state ────────────────────────────────────────────────────────────
const [title, setTitle] = useState(editTrip?.title ?? "");
const [driverId, setDriverId] = useState(editTrip?.driverId ?? "");
const [carId, setCarId] = useState(editTrip?.carId ?? "");
const [branchId, setBranchId] = useState(editTrip?.branchId ?? "");
const [status, setStatus] = useState<TripStatus>(
editTrip?.status ?? "Scheduled",
);
const [startTime, setStartTime] = useState(
editTrip?.startTime?.slice(0, 16) ?? "",
);
const [endTime, setEndTime] = useState(editTrip?.endTime?.slice(0, 16) ?? "");
const [collectedCount, setCollectedCount] = useState<string>(
editTrip?.collectedCount != null ? String(editTrip.collectedCount) : "",
);
const [deliveredCount, setDeliveredCount] = useState<string>(
editTrip?.deliveredCount != null ? String(editTrip.deliveredCount) : "",
);
const [returnedCount, setReturnedCount] = useState<string>(
editTrip?.returnedCount != null ? String(editTrip.returnedCount) : "",
);
const [totalCashCollected, setTotalCashCollected] = useState<string>(
editTrip?.totalCashCollected != null
? String(editTrip.totalCashCollected)
: "",
);
const [notes, setNotes] = useState(editTrip?.notes ?? "");
const [endReason, setEndReason] = useState(editTrip?.endReason ?? "");
const [reason, setReason] = useState(""); // update-only
const [errors, setErrors] = useState<TripSchemaErrors>({});
const [saving, setSaving] = useState(false);
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]);
// ── Error style helper ────────────────────────────────────────────────────
const inputStyle = (field: keyof TripSchemaErrors): React.CSSProperties => ({
...inputBase,
...(errors[field]
? { borderColor: "var(--color-danger)", background: "#FEF2F2" }
: {}),
});
const clearErr = (field: keyof TripSchemaErrors) =>
setErrors((p) => ({ ...p, [field]: undefined }));
// ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const raw: Record<string, unknown> = {
title: title || undefined,
driverId: driverId || undefined,
carId: carId || undefined,
branchId: branchId || undefined,
status: status || undefined,
startTime: startTime ? `${startTime}:00.000Z` : undefined,
endTime: endTime ? `${endTime}:00.000Z` : undefined,
collectedCount:
collectedCount !== "" ? Number(collectedCount) : undefined,
deliveredCount:
deliveredCount !== "" ? Number(deliveredCount) : undefined,
returnedCount: returnedCount !== "" ? Number(returnedCount) : undefined,
totalCashCollected:
totalCashCollected !== "" ? Number(totalCashCollected) : undefined,
notes: notes || undefined,
endReason: endReason || undefined,
...(!isNew && reason ? { reason } : {}),
};
// ── Client-side validation ────────────────────────────────────────────
try {
const schema = isNew ? createTripSchema : updateTripSchema;
await schema.validate(raw, { abortEarly: false });
} catch (err) {
if (err instanceof yup.ValidationError) {
const map: TripSchemaErrors = {};
err.inner.forEach((e) => {
if (e.path) (map as Record<string, string>)[e.path] = e.message;
});
setErrors(map);
}
return;
}
// ── Call parent handler (notification emitted by the hook) ────────────
setSaving(true);
try {
const ok = await onSubmit(
raw as CreateTripPayload | UpdateTripPayload,
isNew,
);
if (ok) onClose();
// on failure the hook already fired an error notification — nothing extra needed here
} finally {
setSaving(false);
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="trip-form-title"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 60,
background: "rgba(15,23,42,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
padding: "2rem 1rem",
overflowY: "auto",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 700,
background: "var(--color-surface)",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 56px rgba(0,0,0,.2)",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<h2
id="trip-form-title"
style={{
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
</h2>
<button
type="button"
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--color-text-muted)",
padding: 4,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
noValidate
style={{
padding: "1.5rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{/* ── Section: أساسيات الرحلة ── */}
<p style={sectionHeadingStyle}>أساسيات الرحلة</p>
{/* Title */}
<label style={labelStyle}>
عنوان الرحلة <span style={{ color: "var(--color-danger)" }}>*</span>
<input
ref={firstRef}
style={inputStyle("title")}
value={title}
onChange={(e) => {
setTitle(e.target.value);
clearErr("title");
}}
placeholder="مثال: توزيع الرياض الشمالي"
dir="rtl"
/>
{errors.title && <span style={errorTextStyle}>{errors.title}</span>}
</label>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
{/* Driver */}
<label style={labelStyle}>
السائق <span style={{ color: "var(--color-danger)" }}>*</span>
<select
style={{ ...inputStyle("driverId"), cursor: "pointer" }}
value={driverId}
onChange={(e) => {
setDriverId(e.target.value);
clearErr("driverId");
}}
dir="rtl"
>
<option value="">اختر السائق</option>
{drivers.map((d) => (
<option key={d.id} value={d.id}>
{d.name} {d.phone}
</option>
))}
</select>
{errors.driverId && (
<span style={errorTextStyle}>{errors.driverId}</span>
)}
</label>
{/* Car */}
<label style={labelStyle}>
السيارة <span style={{ color: "var(--color-danger)" }}>*</span>
<select
style={{ ...inputStyle("carId"), cursor: "pointer" }}
value={carId}
onChange={(e) => {
setCarId(e.target.value);
clearErr("carId");
}}
dir="rtl"
>
<option value="">اختر السيارة</option>
{cars.map((c) => (
<option key={c.id} value={c.id}>
{c.manufacturer} {c.model} {c.plateNumber}
</option>
))}
</select>
{errors.carId && (
<span style={errorTextStyle}>{errors.carId}</span>
)}
</label>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
{/* Branch */}
<label style={labelStyle}>
الفرع <span style={{ color: "var(--color-danger)" }}>*</span>
<select
style={{ ...inputStyle("branchId"), cursor: "pointer" }}
value={branchId}
onChange={(e) => {
setBranchId(e.target.value);
clearErr("branchId");
}}
dir="rtl"
>
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
{errors.branchId && (
<span style={errorTextStyle}>{errors.branchId}</span>
)}
</label>
{/* Status */}
<label style={labelStyle}>
الحالة
<select
style={{ ...inputBase, cursor: "pointer" }}
value={status}
onChange={(e) => setStatus(e.target.value as TripStatus)}
dir="rtl"
>
<option value="Scheduled">مجدولة</option>
<option value="InProgress">جارية</option>
<option value="Completed">مكتملة</option>
<option value="Cancelled">ملغاة</option>
</select>
</label>
</div>
{/* ── Section: التوقيت ── */}
<p style={sectionHeadingStyle}>التوقيت</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
وقت البدء <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="datetime-local"
style={inputStyle("startTime")}
value={startTime}
onChange={(e) => {
setStartTime(e.target.value);
clearErr("startTime");
}}
/>
{errors.startTime && (
<span style={errorTextStyle}>{errors.startTime}</span>
)}
</label>
<label style={labelStyle}>
وقت الانتهاء <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="datetime-local"
style={inputStyle("endTime")}
value={endTime}
onChange={(e) => {
setEndTime(e.target.value);
clearErr("endTime");
}}
/>
{errors.endTime && (
<span style={errorTextStyle}>{errors.endTime}</span>
)}
</label>
</div>
{/* ── Section: الأعداد والمبالغ ── */}
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
المجمّع <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
min="0"
style={inputStyle("collectedCount")}
value={collectedCount}
onChange={(e) => {
setCollectedCount(e.target.value);
clearErr("collectedCount");
}}
placeholder="0"
dir="ltr"
/>
{errors.collectedCount && (
<span style={errorTextStyle}>{errors.collectedCount}</span>
)}
</label>
<label style={labelStyle}>
المُسلَّم <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
min="0"
style={inputStyle("deliveredCount")}
value={deliveredCount}
onChange={(e) => {
setDeliveredCount(e.target.value);
clearErr("deliveredCount");
}}
placeholder="0"
dir="ltr"
/>
{errors.deliveredCount && (
<span style={errorTextStyle}>{errors.deliveredCount}</span>
)}
</label>
<label style={labelStyle}>
المُرتجع <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
min="0"
style={inputStyle("returnedCount")}
value={returnedCount}
onChange={(e) => {
setReturnedCount(e.target.value);
clearErr("returnedCount");
}}
placeholder="0"
dir="ltr"
/>
{errors.returnedCount && (
<span style={errorTextStyle}>{errors.returnedCount}</span>
)}
</label>
<label style={labelStyle}>
النقد المحصّل <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
min="0"
step="0.01"
style={inputStyle("totalCashCollected")}
value={totalCashCollected}
onChange={(e) => {
setTotalCashCollected(e.target.value);
clearErr("totalCashCollected");
}}
placeholder="0.00"
dir="ltr"
/>
{errors.totalCashCollected && (
<span style={errorTextStyle}>{errors.totalCashCollected}</span>
)}
</label>
</div>
{/* ── Section: ملاحظات ── */}
<p style={sectionHeadingStyle}>ملاحظات</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
ملاحظات <span style={optionalLabelStyle}>(اختياري)</span>
<textarea
style={{
...inputBase,
height: 72,
padding: "0.5rem 0.75rem",
resize: "vertical",
}}
value={notes}
onChange={(e) => {
setNotes(e.target.value);
clearErr("notes");
}}
placeholder="أي ملاحظات إضافية…"
dir="rtl"
/>
{errors.notes && (
<span style={errorTextStyle}>{errors.notes}</span>
)}
</label>
<label style={labelStyle}>
سبب الإنهاء <span style={optionalLabelStyle}>(اختياري)</span>
<textarea
style={{
...inputBase,
height: 72,
padding: "0.5rem 0.75rem",
resize: "vertical",
}}
value={endReason}
onChange={(e) => {
setEndReason(e.target.value);
clearErr("endReason");
}}
placeholder="سبب إنهاء أو إلغاء الرحلة…"
dir="rtl"
/>
{errors.endReason && (
<span style={errorTextStyle}>{errors.endReason}</span>
)}
</label>
</div>
{/* reason — edit only (for reassigning driver/car) */}
{!isNew && (
<>
<p style={sectionHeadingStyle}>سجل التغيير</p>
<label style={labelStyle}>
سبب التعديل{" "}
<span style={optionalLabelStyle}>
(مطلوب عند تغيير السائق أو السيارة)
</span>
<input
style={inputBase}
value={reason}
onChange={(e) => setReason(e.target.value)}
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>
);
}

View File

@@ -0,0 +1,2 @@
export { TripFormModal } from "./Tripformmodal";
export { TripDeleteModal } from "./Tripdeletemodal";

View File

@@ -0,0 +1,120 @@
"use client";
import { useRef, useState } from "react";
import { Spinner } from "../UI";
import { Toast } from "../UI/Toast";
import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth";
import type { TripNotification } from "@/src/hooks/useTrip";
interface TripReportPanelProps {
tripId: string;
}
// Extracts a readable message from any error shape the API might return.
function extractApiMessage(err: unknown, fallback: string): string {
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
const rd = (e["response"] as Record<string, unknown> | undefined)?.["data"];
if (rd && typeof rd === "object") {
const msg = (rd as Record<string, unknown>)["message"];
if (typeof msg === "string" && msg.trim()) return msg;
}
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"];
}
return fallback;
}
export function TripReportPanel({ tripId }: TripReportPanelProps) {
const [loading, setLoading] = useState(false);
const [notification, setNotification] = useState<TripNotification | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = (n: TripNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n);
timerRef.current = setTimeout(() => setNotification(null), 4000);
};
const handleGenerate = async () => {
setLoading(true);
try {
const token = getStoredToken();
const res = await tripService.getReport(tripId, token);
const url = (res as unknown as { data: { reportUrl: string } }).data?.reportUrl;
if (!url) {
notify({ type: "error", message: "لم يتم إرجاع رابط التقرير من الخادم." });
return;
}
window.open(url, "_blank", "noopener,noreferrer");
notify({ type: "success", message: "تم إنشاء التقرير بنجاح." });
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر إنشاء التقرير.") });
} 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>
<button
type="button"
onClick={handleGenerate}
disabled={loading}
style={{
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "none",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
background: loading ? "var(--color-brand-400)" : "var(--color-brand-600)",
cursor: loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
whiteSpace: "nowrap",
}}
>
{loading && <Spinner size="sm" className="text-white" />}
{loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"}
</button>
</div>
{/* Toast scoped to this panel — sits at the bottom of the screen */}
<Toast
notification={notification}
onDismiss={() => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(null);
}}
/>
</>
);
}

View File

@@ -0,0 +1,59 @@
// Typed, accessible alert banner with optional dismiss button.
import { cn } from "@/src/lib/utils";
export type AlertType = "error" | "info" | "success" | "warning";
export interface AlertProps {
type?: AlertType;
title?: string;
message: string;
onClose?: () => void;
className?: string;
}
const config: Record<AlertType, { bg: string; icon: string; iconLabel: string }> = {
error: { bg: "bg-red-50 border-red-200 text-red-700", icon: "⚠", iconLabel: "Error" },
info: { bg: "bg-blue-50 border-blue-200 text-blue-700", icon: "", iconLabel: "Info" },
success: { bg: "bg-emerald-50 border-emerald-200 text-emerald-700", icon: "✓", iconLabel: "Success" },
warning: { bg: "bg-amber-50 border-amber-200 text-amber-700", icon: "!", iconLabel: "Warning" },
};
/**
* @example
* <Alert type="error" message="Invalid credentials." onClose={() => setErr(null)} />
* <Alert type="success" title="Saved!" message="Your changes have been applied." />
*/
export function Alert({ type = "error", title, message, onClose, className }: AlertProps) {
const { bg, icon, iconLabel } = config[type];
return (
<div
role="alert"
aria-live="polite"
className={cn(
"flex items-start gap-2 border rounded-[var(--radius-md)] px-3 py-2.5 text-[12px] font-medium",
bg,
className,
)}
>
<span aria-label={iconLabel} className="flex-shrink-0 mt-0.5">
{icon}
</span>
<div className="flex-1 min-w-0">
{title && <p className="font-semibold mb-0.5">{title}</p>}
<p>{message}</p>
</div>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Dismiss alert"
className="flex-shrink-0 opacity-60 hover:opacity-100 text-[16px] leading-none transition-opacity"
>
×
</button>
)}
</div>
);
}

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

View File

@@ -0,0 +1,74 @@
// Design-system button with variant, size, and loading state support.
import { cn } from "@/src/lib/utils";
import { Spinner } from "./Spinner";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
/** Full-width block button */
fullWidth?: boolean;
}
const variantStyles: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary:
"bg-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)] text-white shadow-sm",
secondary:
"bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-muted)]",
ghost:
"text-[var(--color-text-muted)] hover:bg-[var(--color-surface-muted)]",
danger:
"bg-red-600 hover:bg-red-700 text-white",
};
const sizeStyles: Record<NonNullable<ButtonProps["size"]>, string> = {
sm: "h-8 px-3 text-[12px] gap-1.5",
md: "h-10 px-4 text-[13px] gap-2",
lg: "h-11 px-6 text-[14px] gap-2",
};
/**
* @example
* <Button onClick={handleSave}>Save</Button>
* <Button variant="secondary" size="sm">Cancel</Button>
* <Button loading={isSubmitting} fullWidth>Submit</Button>
* <Button variant="danger">Delete</Button>
*/
export function Button({
loading,
variant = "primary",
size = "md",
fullWidth,
children,
className,
disabled,
...rest
}: ButtonProps) {
return (
<button
disabled={loading || disabled}
aria-busy={loading}
className={cn(
"inline-flex items-center justify-center rounded-[var(--radius-md)] font-semibold",
"transition-all active:scale-[.98]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-600)] focus-visible:ring-offset-2",
variantStyles[variant],
sizeStyles[size],
fullWidth && "w-full",
className,
)}
{...rest}
>
{loading && (
<Spinner
size="sm"
className="text-current"
aria-hidden="true"
/>
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,51 @@
import { Button } from "./Button";
import { Modal } from "./ModalProps";
interface ConfirmDialogProps {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
/** Subtitle shown in modal header */
subtitle?: string;
}
export function ConfirmDialog({
open,
title,
description,
confirmLabel = "تأكيد",
cancelLabel = "إلغاء",
onConfirm,
onCancel,
loading = false,
subtitle,
}: ConfirmDialogProps) {
return (
<Modal
open={open}
title={title}
subtitle={subtitle}
onClose={onCancel}
size="sm"
footer={
<>
<Button variant="secondary" onClick={onCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button variant="danger" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</>
}
>
<p className="text-[13px] text-[var(--color-text-muted)] leading-relaxed">
{description}
</p>
</Modal>
);
}

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

View File

@@ -0,0 +1,91 @@
// Accessible labelled input with error and hint state.
import { cn } from "@/src/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
/** Visible label text — also used to derive the `htmlFor` id */
label: string;
/** Validation error message — sets aria-invalid and renders red helper text */
error?: string;
/** Neutral helper / hint text shown below the input */
hint?: string;
/** Optional right-side icon node */
icon?: React.ReactNode;
}
/**
* @example
* <Input
* label="Email"
* type="email"
* placeholder="you@example.com"
* error={errors.email}
* autoComplete="email"
* />
*
* <Input
* label="Password"
* type="password"
* hint="At least 8 characters"
* />
*/
export function Input({ label, error, hint, id, icon, className, ...rest }: InputProps) {
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`;
const errorId = `${inputId}-error`;
const hintId = `${inputId}-hint`;
return (
<div className="flex flex-col gap-1">
<label
htmlFor={inputId}
className="text-[12px] font-semibold text-[var(--color-text-primary)]"
>
{label}
</label>
<div className="relative">
{icon && (
<div
aria-hidden="true"
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-hint)] pointer-events-none"
>
{icon}
</div>
)}
<input
id={inputId}
aria-invalid={!!error}
aria-describedby={
error ? errorId : hint ? hintId : undefined
}
className={cn(
"w-full h-10 px-3 border rounded-[var(--radius-md)] text-[13px]",
"text-[var(--color-text-primary)] placeholder-[var(--color-text-hint)]",
"bg-white outline-none transition",
"focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15",
"focus-visible:ring-[var(--color-brand-600)]",
icon ? "pr-9" : "",
error
? "border-red-400 bg-red-50"
: "border-[var(--color-border)]",
className,
)}
{...rest}
/>
</div>
{error && (
<p id={errorId} role="alert" className="text-[11px] text-red-500 font-medium">
{error}
</p>
)}
{hint && !error && (
<p id={hintId} className="text-[11px] text-[var(--color-text-hint)]">
{hint}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useEffect } from "react";
import { cn } from "@/src/lib/utils";
interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
/** Footer slot typically action buttons */
footer?: React.ReactNode;
size?: "sm" | "md" | "lg";
/** Subtitle or badge shown below the title */
subtitle?: string;
}
const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = {
sm: "max-w-sm",
md: "max-w-lg",
lg: "max-w-2xl",
};
export function Modal({
open,
title,
onClose,
children,
footer,
size = "md",
subtitle,
}: ModalProps) {
// Escape key to close
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
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-[var(--color-slate-900)]/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className={cn(
"relative w-full flex flex-col max-h-[90vh]",
"bg-[var(--color-surface)]",
"rounded-[var(--radius-2xl)]",
"border border-[var(--color-border)]",
"shadow-[var(--shadow-overlay)]",
MODAL_SIZES[size],
)}
>
{/* Header */}
<div
className={cn(
"flex items-center justify-between",
"px-6 py-4",
"border-b border-[var(--color-border)]",
"bg-[var(--color-surface-muted)]",
"rounded-t-[var(--radius-2xl)]",
)}
>
<div>
{subtitle && (
<p className="text-[11px] font-semibold tracking-[0.3em] uppercase text-[var(--color-brand-600)] mb-1">
{subtitle}
</p>
)}
<h2
id="modal-title"
className="text-[17px] font-bold text-[var(--color-text-primary)]"
>
{title}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
className={cn(
"w-[34px] h-[34px] flex items-center justify-center",
"rounded-[var(--radius-md)]",
"border border-[var(--color-border)]",
"bg-[var(--color-surface)]",
"text-[18px] text-[var(--color-text-muted)]",
"hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-sunken)]",
"transition-colors cursor-pointer",
)}
>
×
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{children}
</div>
{/* Footer */}
{footer && (
<div
className={cn(
"flex justify-end gap-2",
"px-6 py-4",
"border-t border-[var(--color-border)]",
"bg-[var(--color-surface-muted)]",
"rounded-b-[var(--radius-2xl)]",
)}
>
{footer}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { cn } from "@/src/lib/utils";
interface PageHeaderProps {
title: string;
description?: string;
action?: React.ReactNode;
backHref?: string;
backLabel?: string;
className?: string;
}
export function PageHeader({
title,
description,
action,
backHref,
backLabel,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
"flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between",
"rounded-[var(--radius-xl)] border border-[var(--color-border)]",
"bg-[var(--color-surface)] shadow-[var(--shadow-card)]",
"px-8 py-6",
className,
)}
>
<div>
{backHref && (
<a
href={backHref}
className={cn(
"mb-2 inline-flex items-center gap-1",
"text-[12px] font-semibold text-[var(--color-brand-600)]",
"hover:text-[var(--color-brand-700)] transition-colors",
)}
>
{backLabel ?? "رجوع"}
</a>
)}
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]">
{title}
</h1>
{description && (
<p className="mt-1 text-[13px] text-[var(--color-text-muted)]">
{description}
</p>
)}
</div>
{action && (
<div className="mt-3 sm:mt-0 shrink-0">
{action}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import React, { forwardRef, SelectHTMLAttributes } from "react";
import { cn } from "@/src/lib/utils";
// ─── 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={cn("flex flex-col gap-1", wrapperClassName)}>
{label && (
<label
htmlFor={selectId}
className="text-[12px] font-semibold text-[var(--color-text-muted)] uppercase tracking-wide"
>
{label}
</label>
)}
<select
ref={ref}
id={selectId}
className={cn(
"w-full h-10 rounded-[var(--radius-md)] border px-3 text-[13px]",
"bg-[var(--color-surface)] text-[var(--color-text-primary)]",
"transition-[border-color,box-shadow]",
"focus:outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15",
"disabled:bg-[var(--color-surface-muted)] disabled:text-[var(--color-text-hint)] disabled:cursor-not-allowed",
error
? "border-[var(--color-danger)] bg-[var(--color-danger-light)]"
: "border-[var(--color-border)]",
className,
)}
{...rest}
>
{children}
</select>
{error && (
<p className="text-[11px] font-medium text-[var(--color-danger)]">
{error}
</p>
)}
</div>
);
});

View File

@@ -0,0 +1,80 @@
import { cn } from "@/src/lib/utils";
export interface SpinnerProps {
/** Visual size of the spinner */
size?: "sm" | "md" | "lg";
/** Additional Tailwind classes — use `text-{color}` to tint */
className?: string;
}
const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-10 w-10 border-4",
};
/**
* @example
* // Default (medium, brand blue)
* <Spinner />
*
* // Large, white (inside a dark button)
* <Spinner size="lg" className="text-white" />
*
* // Small, inline
* <Spinner size="sm" className="text-emerald-600" />
*/
export function Spinner({ size = "md", className }: SpinnerProps) {
return (
<svg
role="status"
aria-label="Loading"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className={cn(
"motion-safe:animate-spin text-[var(--color-brand-600)] shrink-0",
sizeMap[size],
className,
)}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v8H4z"
/>
</svg>
);
}
/** Full-page loading overlay */
export function PageLoader({ message = "Loading…" }: { message?: string }) {
return (
<div
role="status"
aria-label={message}
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-surface)]"
>
<Spinner size="lg" />
<p className="text-[13px] text-[var(--color-text-muted)]">{message}</p>
</div>
);
}
/** Inline loading row — for card/section loading states */
export function InlineLoader({ message = "Loading…" }: { message?: string }) {
return (
<div role="status" className="flex items-center gap-2 py-4">
<Spinner size="sm" />
<span className="text-[13px] text-[var(--color-text-muted)]">{message}</span>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { forwardRef } from "react";
import { cn } from "@/src/lib/utils";
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
wrapperClassName?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ label, error, hint, wrapperClassName = "", className = "", id, ...rest },
ref
) {
const taId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
const errorId = taId ? `${taId}-error` : undefined;
const hintId = taId ? `${taId}-hint` : undefined;
return (
<div className={cn("flex flex-col gap-1", wrapperClassName)}>
{label && (
<label
htmlFor={taId}
className="text-[12px] font-semibold text-[var(--color-text-muted)] uppercase tracking-wide"
>
{label}
</label>
)}
<textarea
ref={ref}
id={taId}
rows={3}
aria-invalid={!!error}
aria-describedby={error ? errorId : hint ? hintId : undefined}
className={cn(
"w-full rounded-[var(--radius-md)] border px-3 py-2 text-[13px] resize-none",
"bg-[var(--color-surface)] text-[var(--color-text-primary)]",
"placeholder:text-[var(--color-text-hint)]",
"transition-[border-color,box-shadow]",
"focus:outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15",
"disabled:bg-[var(--color-surface-muted)] disabled:text-[var(--color-text-hint)]",
error
? "border-[var(--color-danger)] bg-[var(--color-danger-light)]"
: "border-[var(--color-border)]",
className,
)}
{...rest}
/>
{error && (
<p id={errorId} role="alert" className="text-[11px] font-medium text-[var(--color-danger)]">
{error}
</p>
)}
{hint && !error && (
<p id={hintId} className="text-[11px] text-[var(--color-text-hint)]">
{hint}
</p>
)}
</div>
);
});

101
src/Components/UI/Toast.tsx Normal file
View File

@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef, useState } from "react";
export interface ToastNotification {
type: "success" | "error";
message: string;
}
interface ToastProps {
notification: ToastNotification | null;
onDismiss?: () => void;
}
export function Toast({ notification, onDismiss }: ToastProps) {
// `visible` drives the mount/unmount; stays true for 300 ms after
// `notification` clears so the CSS exit transition has time to finish.
const [visible, setVisible] = useState(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (notification) {
if (exitTimer.current) clearTimeout(exitTimer.current);
setVisible(true);
} else {
exitTimer.current = setTimeout(() => setVisible(false), 300);
}
return () => { if (exitTimer.current) clearTimeout(exitTimer.current); };
}, [notification]);
if (!visible && !notification) return null;
const isSuccess = notification?.type === "success";
const isShowing = !!notification;
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: `translateX(-50%) translateY(${isShowing ? "0" : "16px"})`,
zIndex: 9999,
transition: "transform 250ms ease, opacity 250ms ease",
opacity: isShowing ? 1 : 0,
pointerEvents: isShowing ? "auto" : "none",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0.75rem 1rem 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",
fontFamily: "var(--font-sans)",
}}
>
<span style={{ fontSize: 16, flexShrink: 0 }}>
{isSuccess ? "✓" : "⚠"}
</span>
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{notification?.message}
</span>
{/* Dismiss button — only renders when a handler is provided */}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="إغلاق الإشعار"
style={{
marginLeft: 4,
flexShrink: 0,
background: "none",
border: "none",
color: "rgba(255,255,255,0.7)",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: "0 2px",
fontFamily: "var(--font-sans)",
}}
>
×
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
export { Spinner, PageLoader, InlineLoader } from "./Spinner";
export type { SpinnerProps } from "./Spinner";
export { Alert } from "./Alert";
export type { AlertProps, AlertType } from "./Alert";
export { Button } from "./Button";
export type { ButtonProps } from "./Button";
export { Input } from "./Input";
export type { InputProps } from "./Input";
export { Textarea } from "./Textarea";
export { Select } from "./Select";
export { Toast } from "./Toast";
export type { ToastNotification } from "./Toast";
export { Modal } from "./ModalProps";
export { ConfirmDialog } from "./ConfirmDialog";
export { PageHeader } from "./PageHeader";
export { EmptyState } from "./EmptyState";
export { Badge } from "./Badge";

View File

@@ -0,0 +1,58 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { User } from "@/src/types/user";
interface DeleteConfirmModalProps {
user: User;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteConfirmModal({ user, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
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-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-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)" }}>{user.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>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
import type { Notification } from "@/src/hooks/useUser";
interface ToastProps {
notification: Notification | null;
}
export function Toast({ notification }: ToastProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (notification) {
setVisible(true);
} else {
// dismiss after 250ms to allow exit animation
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)",
}}>
{/* icon */}
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
<span>{notification?.message}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,241 @@
"use client";
import { useEffect, useState } from "react";
import { Spinner } from "../UI";
import { getStoredToken } from "@/src/lib/auth";
import { userService } from "@/src/services/user.service";
import type { UserDetail } from "@/src/types/user";
interface UserDetailModalProps {
userId: string;
onClose: () => void;
}
// ── small helper components ───────────────────────────────────────────────────
function DetailRow({ label, value }: { label: string; value?: string | null }) {
return (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function Avatar({ name }: { name: string }) {
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 22, fontWeight: 700, color: "#FFF",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
}}>
{initials}
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
const [user, setUser] = useState<UserDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// fetch user details on mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const token = getStoredToken();
const res = await userService.getById(userId, token);
if (!cancelled) setUser(res.data);
} catch {
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [userId]);
// ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) =>
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
// ── render ────────────────────────────────────────────────────────────────
return (
<div
role="dialog" aria-modal="true" aria-labelledby="detail-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 55,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
بيانات المستخدم
</p>
<h2 id="detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{user?.name ?? "عرض المستخدم"}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && (
<div style={{
padding: "1rem 1.25rem",
borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, color: "#991B1B", fontWeight: 500,
textAlign: "center",
}}>
{error}
</div>
)}
{/* content */}
{!loading && user && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* avatar + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<Avatar name={user.name} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{user.name}</p>
{user.userName && (
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{user.userName}
</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={user.isActive} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={user.phone} />
<DetailRow label="البريد الإلكتروني" value={user.email} />
<DetailRow label="الدور" value={user.role?.name} />
<DetailRow label="وصف الدور" value={user.role?.description} />
<DetailRow label="الفرع" value={user.branch?.name} />
<DetailRow label="تاريخ الإنشاء" value={fmt(user.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
{user.passwordChangedAt && (
<DetailRow label="آخر تغيير لكلمة المرور" value={fmt(user.passwordChangedAt)} />
)}
</div>
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,167 @@
"use client";
import { Spinner } from "../UI";
import type { User } from "@/src/types/user";
// ── role badge ───────────────────────────────────────────────────────────────
function RoleBadge({ name }: { name?: string }) {
const isAdmin = name === "مدير النظام";
return (
<span style={{ display: "inline-flex", alignItems: "center", borderRadius: "var(--radius-full)", border: isAdmin ? "1px solid #BFDBFE" : "1px solid var(--color-border)", background: isAdmin ? "#EFF6FF" : "var(--color-surface-muted)", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: isAdmin ? "#1D4ED8" : "var(--color-text-muted)" }}>
{name ?? "—"}
</span>
);
}
// ── status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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 UserTableProps {
users: User[];
loading: boolean;
search: string;
page: number;
pages: number;
onEdit: (user: User) => void;
onDelete: (user: User) => void;
onView: (user: User) => void;
onAddFirst: () => void;
onPageChange: (p: number) => void;
}
// ── main table ───────────────────────────────────────────────────────────────
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: UserTableProps) {
return (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
<span>الاسم</span>
<span>اسم المستخدم</span>
<span>الفرع</span>
<span>الدور</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{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>
) : users.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
</p>
{!search && (
<button type="button" onClick={onAddFirst}
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
أضف أول مستخدم
</button>
)}
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{users.map((u, i) => (
<li key={u.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px",
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,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
<span style={{ color: "var(--color-text-secondary)" }}>{u.branch?.name ?? "—"}</span>
<RoleBadge name={u.role?.name} />
<StatusBadge active={u.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view */}
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
{/* edit */}
<IconBtn title={`تعديل ${u.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(u)}>
<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>
{/* delete */}
<IconBtn title={`حذف ${u.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(u)}>
<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>
);
}

View File

@@ -0,0 +1,5 @@
export { UserTable } from "./UserTable";
export { UserFormModal } from "./UserFormModal";
export { UserDetailModal } from "./UserDetailModal";
export { DeleteConfirmModal } from "./DeleteConfirmModal";
export { Toast } from "./Toast";

View File

@@ -0,0 +1,86 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Car } from "@/src/types/car";
interface CarDeleteModalProps {
car: Car;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function CarDeleteModal({ car, deleting, onCancel, onConfirm }: CarDeleteModalProps) {
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="car-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="car-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)" }}>
{car.manufacturer} {car.model}
</strong>
{" "}(
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
{car.plateLetters} {car.plateNumber}
</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>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useEffect, useState } from "react";
import { Spinner } from "../UI";
import { CarImageGallery } from "./CarImageGallery";
import { useCarDetail } from "@/src/hooks/useCars";
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
import type { Car, InsuranceStatus } from "@/src/types/car";
// ── Props ─────────────────────────────────────────────────────────────────────
interface CarDetailPanelProps {
carId: string;
onClose: () => void;
onEdit: (car: Car) => void;
onDelete: (car: Car) => void;
}
// ── Row component ─────────────────────────────────────────────────────────────
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.6rem 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,
}}>
{warn && value !== "—" ? "⚠ " : ""}{value}
</span>
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPanelProps) {
const { car, loading, error } = useCarDetail(carId);
const [gallery, setGallery] = useState(false);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape" && !gallery) onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [gallery, onClose]);
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 40,
background: "rgba(15,23,42,0.45)", backdropFilter: "blur(2px)",
}}
/>
{/* Panel */}
<aside
aria-label="تفاصيل المركبة"
style={{
position: "fixed", top: 0, left: 0, bottom: 0, zIndex: 50,
width: "min(480px, 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" }}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
تفاصيل المركبة
</p>
{car && (
<h2 style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{car.manufacturer} {car.model} {car.year}
</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", flexShrink: 0 }}>
×
</button>
</div>
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
{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 && (
<div style={{ borderRadius: "var(--radius-md)", background: "#FEF2F2", border: "1px solid #FECACA", padding: "0.75rem 1rem", fontSize: 13, color: "#DC2626" }}>
{error}
</div>
)}
{car && !loading && (
<>
{/* Status badges */}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
{(() => {
const s = STATUS_MAP[car.currentStatus];
return (
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: s.color }}>
{s.label}
</span>
);
})()}
{car.insuranceStatus && (() => {
const ins = INS_MAP[car.insuranceStatus as InsuranceStatus];
return (
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${ins.color}33`, background: `${ins.color}11`, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: ins.color }}>
تأمين: {ins.label}
</span>
);
})()}
{!car.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: Basic Info */}
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, marginBottom: "0.5rem" }}>
بيانات أساسية
</p>
<DetailRow label="الشركة المصنعة" value={car.manufacturer} />
<DetailRow label="الموديل" value={car.model} />
<DetailRow label="سنة الصنع" value={String(car.year)} />
<DetailRow label="اللون" value={car.color ?? "—"} />
<DetailRow label="الفرع" value={car.branch?.name ?? "—"} />
{/* Section: Plate */}
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
بيانات اللوحة
</p>
<DetailRow label="رقم اللوحة" value={`${car.plateLetters} ${car.plateNumber}`} mono />
<DetailRow label="نوع اللوحة" value={car.plateType ?? "—"} />
<DetailRow label="رقم الاستمارة" value={car.registrationNumber ?? "—"} mono />
<DetailRow label="رقم الهيكل (VIN)" value={car.vinNumber ?? "—"} mono />
{/* Section: Regulatory */}
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
الترخيص والتأمين
</p>
<DetailRow label="انتهاء الاستمارة" value={fmtDate(car.registrationExpiryDate)} warn={isExpiringSoon(car.registrationExpiryDate)} />
<DetailRow label="انتهاء التأمين" value={fmtDate(car.insuranceExpiryDate)} warn={isExpiringSoon(car.insuranceExpiryDate)} />
<DetailRow label="انتهاء الفحص الدوري" value={fmtDate(car.inspectionExpiryDate)} warn={isExpiringSoon(car.inspectionExpiryDate)} />
<DetailRow label="رقم بطاقة التشغيل" value={car.operationCardNumber ?? "—"} mono />
<DetailRow label="انتهاء بطاقة التشغيل" value={fmtDate(car.operationCardExpiry)} />
{/* Section: Operational */}
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
بيانات تشغيلية
</p>
<DetailRow label="رقم GPS" value={car.gpsDeviceId ?? "—"} mono />
<DetailRow label="الطاقة الاستيعابية" value={car.capacity != null ? String(car.capacity) : "—"} />
<DetailRow label="الوزن (كجم)" value={car.weight != null ? String(car.weight) : "—"} />
<DetailRow label="حالة الحجز" value={car.currentImpoundStatus ?? "بدون"} />
<DetailRow label="تاريخ الإضافة" value={fmtDate(car.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(car.updatedAt)} />
{/* Status History */}
{car.statusHistory && car.statusHistory.length > 0 && (
<>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.75rem" }}>
سجل الحالات
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{car.statusHistory.slice(0, 6).map(h => {
const s = STATUS_MAP[h.carStatus] ?? 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",
}}>
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{fmtDate(h.createdAt)}
</span>
</div>
);
})}
</div>
</>
)}
</>
)}
</div>
{/* Footer actions */}
{car && (
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", gap: "0.75rem",
flexShrink: 0,
}}>
<button type="button" onClick={() => setGallery(true)}
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", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
الصور
</button>
<button type="button" onClick={() => onEdit(car)}
style={{ flex: 2, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<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>
تعديل
</button>
<button type="button" onClick={() => onDelete(car)}
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>
</div>
)}
</aside>
{gallery && car && (
<CarImageGallery car={car} onClose={() => setGallery(false)} />
)}
</>
);
}

View File

@@ -0,0 +1,787 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as yup from "yup";
import { Alert, Spinner } from "../UI";
import { get } from "@/src/services/api";
import { getStoredToken } from "@/src/lib/auth";
import { createCarSchema, updateCarSchema } from "@/src/validations/car.validator";
import type {
Car,
CarFormErrors,
CreateCarPayload,
InsuranceStatus,
UpdateCarPayload,
} from "@/src/types/car";
import type { Branch } from "@/src/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,
};
// ── yup validation ────────────────────────────────────────────────────────────
async function validate(
form: Partial<CreateCarPayload>,
isNew: boolean,
): Promise<CarFormErrors> {
const schema = isNew ? createCarSchema : updateCarSchema;
try {
await schema.validate(form, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<CarFormErrors>((acc, e) => {
const field = e.path as keyof CarFormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
}
// ── Date helper ───────────────────────────────────────────────────────────────
// <input type="date"> gives "YYYY-MM-DD"; backend expects full ISO-8601.
function toIsoDateTime(val: string): string {
if (!val) return val;
if (val.includes("T")) return val;
return `${val}T00:00:00.000Z`;
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface CarFormModalProps {
editCar: Car | null;
/** Pass pre-loaded branches or an empty array — the modal will auto-fetch if empty. */
branches: Branch[];
onClose: () => void;
onSubmit: (
payload: CreateCarPayload | UpdateCarPayload,
isNew: boolean,
) => Promise<boolean>;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CarFormModal({
editCar,
branches: branchesProp,
onClose,
onSubmit,
}: CarFormModalProps) {
const isNew = editCar === 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 [manufacturer, setManufacturer] = useState(editCar?.manufacturer ?? "");
const [model, setModel] = useState(editCar?.model ?? "");
const [color, setColor] = useState(editCar?.color ?? "");
const [plateNumber, setPlateNumber] = useState(editCar?.plateNumber ?? "");
const [plateLetters, setPlateLetters] = useState(editCar?.plateLetters ?? "");
const [plateType, setPlateType] = useState(editCar?.plateType ?? "");
const [registrationNumber, setRegistrationNumber] = useState(
editCar?.registrationNumber ?? "",
);
const [vinNumber, setVinNumber] = useState(editCar?.vinNumber ?? "");
const [branchId, setBranchId] = useState(editCar?.branch?.id ?? "");
const [currentStatus, setCurrentStatus] = useState<Car["currentStatus"]>(
editCar?.currentStatus ?? "Active",
);
const [insuranceStatus, setInsuranceStatus] = useState<InsuranceStatus>(
(editCar?.insuranceStatus as InsuranceStatus) ?? "Valid",
);
const [registrationExpiryDate, setRegistrationExpiryDate] = useState(
editCar?.registrationExpiryDate?.slice(0, 10) ?? "",
);
const [insuranceExpiryDate, setInsuranceExpiryDate] = useState(
editCar?.insuranceExpiryDate?.slice(0, 10) ?? "",
);
const [inspectionExpiryDate, setInspectionExpiryDate] = useState(
editCar?.inspectionExpiryDate?.slice(0, 10) ?? "",
);
const [gpsDeviceId, setGpsDeviceId] = useState(editCar?.gpsDeviceId ?? "");
const [year, setYear] = useState<number | undefined>(
editCar?.year ?? new Date().getFullYear(),
);
const [capacity, setCapacity] = useState<number | undefined>(
editCar?.capacity ?? undefined,
);
const [weight, setWeight] = useState<number | undefined>(
editCar?.weight ?? undefined,
);
const [errors, setErrors] = useState<CarFormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstRef = useRef<HTMLInputElement>(null);
useEffect(() => {
firstRef.current?.focus();
}, []);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const parseNum = (v: string): number | undefined =>
v.trim() === "" ? undefined : Number(v);
const inputStyle = (field: keyof CarFormErrors): React.CSSProperties => ({
...inputBase,
...(errors[field]
? { borderColor: "var(--color-danger)", background: "#FEF2F2" }
: {}),
});
const clearFieldError = (field: keyof CarFormErrors) =>
setErrors((p) => ({ ...p, [field]: undefined }));
// ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Build snapshot for yup — include all fields so optional rules also run
const formSnapshot: Partial<CreateCarPayload> = {
manufacturer,
model,
year,
plateNumber,
plateLetters,
currentStatus,
insuranceStatus,
registrationNumber,
vinNumber,
...(color && { color }),
...(plateType && { plateType }),
...(branchId && { branchId }),
...(registrationExpiryDate && { registrationExpiryDate }),
...(insuranceExpiryDate && { insuranceExpiryDate }),
...(inspectionExpiryDate && { inspectionExpiryDate }),
...(gpsDeviceId && { gpsDeviceId }),
...(capacity !== undefined && { capacity }),
...(weight !== undefined && { weight }),
};
const errs = await validate(formSnapshot, isNew);
if (Object.keys(errs).length) {
setErrors(errs);
return;
}
// Build final payload — convert date strings to ISO-8601 for the backend
const raw: Record<string, unknown> = {
manufacturer,
model,
year,
plateNumber,
plateLetters,
currentStatus,
};
if (color) raw.color = color;
if (plateType) raw.plateType = plateType;
if (registrationNumber) raw.registrationNumber = registrationNumber;
if (vinNumber) raw.vinNumber = vinNumber;
if (branchId) raw.branchId = branchId;
if (insuranceStatus) raw.insuranceStatus = insuranceStatus;
if (registrationExpiryDate)
raw.registrationExpiryDate = toIsoDateTime(registrationExpiryDate);
if (insuranceExpiryDate)
raw.insuranceExpiryDate = toIsoDateTime(insuranceExpiryDate);
if (inspectionExpiryDate)
raw.inspectionExpiryDate = toIsoDateTime(inspectionExpiryDate);
if (gpsDeviceId) raw.gpsDeviceId = gpsDeviceId;
if (capacity !== undefined) raw.capacity = capacity;
if (weight !== undefined) raw.weight = weight;
const payload = raw as unknown as CreateCarPayload;
setSaving(true);
setApiError("");
const ok = await onSubmit(payload, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="car-modal-title"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
background: "rgba(15,23,42,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
overflowY: "auto",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 620,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
margin: "auto",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}
>
<div>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
{isNew ? "إضافة مركبة" : "تعديل مركبة"}
</p>
<h2
id="car-modal-title"
style={{
fontSize: 17,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: "4px 0 0",
}}
>
{isNew
? "مركبة جديدة"
: `${editCar?.manufacturer} ${editCar?.model}`}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
style={{
width: 34,
height: 34,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer",
fontSize: 18,
color: "var(--color-text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
×
</button>
</div>
{/* Form */}
<form
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: Basic Info ── */}
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: 0,
}}
>
بيانات أساسية
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
الشركة المصنعة *
<input
ref={firstRef}
style={inputStyle("manufacturer")}
value={manufacturer}
onChange={(e) => {
setManufacturer(e.target.value);
clearFieldError("manufacturer");
}}
placeholder="تويوتا"
dir="rtl"
autoComplete="off"
/>
{errors.manufacturer && (
<span style={errorTextStyle}>{errors.manufacturer}</span>
)}
</label>
<label style={labelStyle}>
الموديل *
<input
style={inputStyle("model")}
value={model}
onChange={(e) => {
setModel(e.target.value);
clearFieldError("model");
}}
placeholder="لاند كروزر"
dir="rtl"
/>
{errors.model && (
<span style={errorTextStyle}>{errors.model}</span>
)}
</label>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
سنة الصنع *
<input
style={inputStyle("year")}
type="number"
min={1900}
max={new Date().getFullYear() + 1}
value={year ?? ""}
onChange={(e) => {
setYear(parseNum(e.target.value));
clearFieldError("year");
}}
dir="ltr"
/>
{errors.year && <span style={errorTextStyle}>{errors.year}</span>}
</label>
<label style={labelStyle}>
اللون
<input
style={inputBase}
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="أبيض"
dir="rtl"
/>
</label>
<label style={labelStyle}>
نوع اللوحة
<input
style={inputBase}
value={plateType}
onChange={(e) => setPlateType(e.target.value)}
placeholder="خاص"
dir="rtl"
/>
</label>
</div>
{/* ── Section: Plate ── */}
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: "0.5rem 0 0",
}}
>
بيانات اللوحة
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
رقم اللوحة *
<input
style={inputStyle("plateNumber")}
value={plateNumber}
onChange={(e) => {
setPlateNumber(e.target.value);
clearFieldError("plateNumber");
}}
placeholder="1234"
dir="ltr"
/>
{errors.plateNumber && (
<span style={errorTextStyle}>{errors.plateNumber}</span>
)}
</label>
<label style={labelStyle}>
حروف اللوحة *
<input
style={inputStyle("plateLetters")}
value={plateLetters}
onChange={(e) => {
setPlateLetters(e.target.value);
clearFieldError("plateLetters");
}}
placeholder="أ ب ج"
dir="rtl"
/>
{errors.plateLetters && (
<span style={errorTextStyle}>{errors.plateLetters}</span>
)}
</label>
</div>
{/* ── Section: Registration & Legal ── */}
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: "0.5rem 0 0",
}}
>
الترخيص والتأمين
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
رقم الاستمارة *
<input
style={inputStyle("registrationNumber")}
value={registrationNumber}
onChange={(e) => {
setRegistrationNumber(e.target.value);
clearFieldError("registrationNumber");
}}
placeholder="SA-001234"
dir="ltr"
/>
{errors.registrationNumber && (
<span style={errorTextStyle}>{errors.registrationNumber}</span>
)}
</label>
<label style={labelStyle}>
رقم الهيكل (VIN)
<input
style={inputStyle("vinNumber")}
value={vinNumber}
onChange={(e) => {
setVinNumber(e.target.value);
clearFieldError("vinNumber");
}}
placeholder="1HGBH41JXMN109186"
dir="ltr"
/>
{errors.vinNumber && (
<span style={errorTextStyle}>{errors.vinNumber}</span>
)}
</label>
<label style={labelStyle}>
انتهاء الاستمارة
<input
style={inputBase}
type="date"
value={registrationExpiryDate}
onChange={(e) => setRegistrationExpiryDate(e.target.value)}
/>
</label>
<label style={labelStyle}>
حالة التأمين
<select
style={{ ...inputBase, cursor: "pointer" }}
value={insuranceStatus}
onChange={(e) =>
setInsuranceStatus(e.target.value as InsuranceStatus)
}
dir="rtl"
>
<option value="Valid">سارٍ</option>
<option value="Expired">منتهي</option>
<option value="NotInsured">غير مؤمَّن</option>
</select>
</label>
<label style={labelStyle}>
انتهاء التأمين
<input
style={inputBase}
type="date"
value={insuranceExpiryDate}
onChange={(e) => setInsuranceExpiryDate(e.target.value)}
/>
</label>
<label style={labelStyle}>
انتهاء الفحص الدوري
<input
style={inputBase}
type="date"
value={inspectionExpiryDate}
onChange={(e) => setInspectionExpiryDate(e.target.value)}
/>
</label>
</div>
{/* ── Section: Operational ── */}
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: "0.5rem 0 0",
}}
>
بيانات تشغيلية
</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}>
الحالة
<select
style={{ ...inputBase, cursor: "pointer" }}
value={currentStatus}
onChange={(e) =>
setCurrentStatus(e.target.value as Car["currentStatus"])
}
dir="rtl"
>
<option value="Active">نشط</option>
<option value="InMaintenance">صيانة</option>
<option value="InTrip">في رحلة</option>
<option value="Inactive">غير نشط</option>
</select>
</label>
<label style={labelStyle}>
رقم GPS
<input
style={inputBase}
value={gpsDeviceId}
onChange={(e) => setGpsDeviceId(e.target.value)}
placeholder="GPS-001"
dir="ltr"
/>
</label>
<label style={labelStyle}>
الطاقة الاستيعابية
<input
style={inputStyle("capacity")}
type="number"
min={0}
value={capacity ?? ""}
onChange={(e) => {
setCapacity(parseNum(e.target.value));
clearFieldError("capacity");
}}
placeholder="0"
dir="ltr"
/>
{errors.capacity && (
<span style={errorTextStyle}>{errors.capacity}</span>
)}
</label>
<label style={labelStyle}>
الوزن (كجم)
<input
style={inputStyle("weight")}
type="number"
min={0}
value={weight ?? ""}
onChange={(e) => {
setWeight(parseNum(e.target.value));
clearFieldError("weight");
}}
placeholder="0"
dir="ltr"
/>
{errors.weight && (
<span style={errorTextStyle}>{errors.weight}</span>
)}
</label>
</div>
{/* ── Actions ── */}
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
paddingTop: "0.5rem",
}}
>
<button
type="button"
onClick={onClose}
disabled={saving}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: saving ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={saving}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: saving
? "var(--color-brand-400)"
: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: saving ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{saving && <Spinner size="sm" className="text-white" />}
{saving
? "جارٍ الحفظ…"
: isNew
? "إضافة المركبة"
: "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
// Components/Car/CarImageGallery.tsx
// Full-screen gallery modal for viewing, uploading and deleting car images.
import { useRef, useState } from "react";
import { Spinner } from "../UI";
import { useCarImages } from "@/src/hooks/useCars";
import { STAGE_MAP } from "@/src/types/car";
import type { Car, CarImage, ImageStage } from "@/src/types/car";
// ── Props ─────────────────────────────────────────────────────────────────────
interface CarImageGalleryProps {
car: Car;
onClose: () => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
const [stage, setStage] = useState<ImageStage>("GENERAL");
const [sortBy, setSortBy] = useState<"asc" | "desc">("desc");
const [lightbox, setLightbox] = useState<CarImage | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const {
images, loading, uploading, deleting, error, setError,
uploadImages, deleteImage,
} = useCarImages(car.id, sortBy);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
await uploadImages(files, stage);
if (fileRef.current) fileRef.current.value = "";
};
const handleDelete = async (imageId: string) => {
const ok = await deleteImage(imageId);
if (ok && lightbox?.id === imageId) setLightbox(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
if (lightbox) setLightbox(null);
else onClose();
}
};
const imgSrc = (img: CarImage) => img.url ?? `/api/proxy/car-photos/${img.image}`;
return (
<div
role="dialog" aria-modal="true" aria-label="معرض صور المركبة"
onKeyDown={handleKeyDown}
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 70,
background: "rgba(15,23,42,0.7)", backdropFilter: "blur(6px)",
display: "flex", flexDirection: "column",
}}
>
{/* ── Header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1rem 1.5rem",
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
flexShrink: 0,
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
معرض الصور
</p>
<h2 style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: "2px 0 0" }}>
{car.manufacturer} {car.model} {car.plateLetters} {car.plateNumber}
</h2>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
{/* Sort */}
<select value={sortBy} onChange={e => setSortBy(e.target.value as "asc" | "desc")}
style={{ height: 36, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 12, color: "var(--color-text-secondary)", padding: "0 0.75rem", outline: "none", fontFamily: "var(--font-sans)" }}>
<option value="desc">الأحدث أولاً</option>
<option value="asc">الأقدم أولاً</option>
</select>
{/* Stage selector for upload */}
<select value={stage} onChange={e => setStage(e.target.value as ImageStage)}
style={{ height: 36, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 12, color: "var(--color-text-secondary)", padding: "0 0.75rem", outline: "none", fontFamily: "var(--font-sans)" }}>
<option value="GENERAL">عام</option>
<option value="BEFORE">قبل</option>
<option value="AFTER">بعد</option>
</select>
{/* Upload button */}
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploading}
style={{ height: 36, padding: "0 1rem", borderRadius: "var(--radius-md)", border: "none", background: uploading ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: uploading ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
{uploading
? <><Spinner size="sm" className="text-white" /> جارٍ الرفع</>
: <>
<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>
<input ref={fileRef} type="file" accept="image/*" multiple style={{ display: "none" }} onChange={handleUpload} />
{/* Close */}
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 36, height: 36, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 20, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
</div>
{/* ── Error ── */}
{error && (
<div style={{ padding: "0.75rem 1.5rem", background: "#FEF2F2", borderBottom: "1px solid #FECACA", fontSize: 13, color: "#DC2626", fontWeight: 500 }}>
{error}
<button onClick={() => setError(null)} style={{ marginRight: 8, fontSize: 14, background: "none", border: "none", color: "#DC2626", cursor: "pointer" }}>×</button>
</div>
)}
{/* ── Gallery Grid ── */}
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0" }}>
<Spinner size="lg" />
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>جارٍ تحميل الصور</span>
</div>
) : images.length === 0 ? (
<div style={{ textAlign: "center", padding: "5rem 0" }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>📸</div>
<p style={{ fontSize: 15, color: "var(--color-text-muted)", fontWeight: 600 }}>لا توجد صور بعد</p>
<p style={{ fontSize: 13, color: "var(--color-text-hint)", marginTop: 4 }}>
اضغط على &quot;رفع صور&quot; لإضافة أولى الصور لهذه المركبة.
</p>
</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: "1rem" }}>
{images.map(img => {
const stageInfo = STAGE_MAP[img.stage ?? "GENERAL"] ?? STAGE_MAP.GENERAL;
const isDeleting = deleting === img.id;
return (
<div key={img.id} style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
opacity: isDeleting ? 0.5 : 1,
transition: "opacity 200ms",
}}>
<div style={{ position: "relative", paddingBottom: "70%", cursor: "pointer" }} onClick={() => setLightbox(img)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc(img)}
alt={`صورة ${stageInfo.label}`}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }}
/>
<span style={{
position: "absolute", top: 8, right: 8,
borderRadius: "var(--radius-full)",
background: stageInfo.bg, color: stageInfo.color,
padding: "0.2rem 0.625rem",
fontSize: 10, fontWeight: 700,
boxShadow: "0 1px 4px rgba(0,0,0,.12)",
}}>
{stageInfo.label}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.6rem 0.75rem", borderTop: "1px solid var(--color-border)" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(img.createdAt).toLocaleDateString("ar-SA", { month: "short", day: "numeric" })}
</span>
<button type="button" onClick={() => handleDelete(img.id)} disabled={isDeleting} aria-label="حذف الصورة"
style={{ width: 28, height: 28, borderRadius: "var(--radius-sm)", border: "1px solid #FECACA", background: "#FEF2F2", color: "#DC2626", cursor: isDeleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
{isDeleting
? <Spinner size="sm" className="text-red-600" />
: <svg width="12" height="12" 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" />
</svg>
}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Lightbox ── */}
{lightbox && (
<div onClick={() => setLightbox(null)} style={{ position: "fixed", inset: 0, zIndex: 80, background: "rgba(0,0,0,0.9)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc(lightbox)}
alt="عرض مكبَّر"
style={{ maxWidth: "90vw", maxHeight: "90vh", borderRadius: "var(--radius-lg)", objectFit: "contain" }}
onClick={e => e.stopPropagation()}
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }}
/>
<button onClick={() => setLightbox(null)} style={{ position: "absolute", top: 24, right: 24, width: 40, height: 40, borderRadius: "50%", background: "rgba(255,255,255,0.15)", border: "none", color: "#fff", fontSize: 20, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { CarFormModal } from "./CarFormModal";
export { CarDetailPanel } from "./CarDetailPanel";
export { CarDeleteModal } from "./CarDeleteModal";
export { CarImageGallery } from "./CarImageGallery";

View File

@@ -0,0 +1,130 @@
"use client";
// Components/Role/DeleteRoleModal.tsx
// Confirm deletion of a role — mirrors DeleteConfirmModal from Users.
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Role } from "../../../services/role.service";
interface DeleteRoleModalProps {
role: Role;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onCancel]);
const permCount = role.permissions?.length ?? 0;
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="del-role-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">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<div>
<h2 id="del-role-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)" }}>{role.name}</strong>؟
</p>
{permCount > 0 && (
<p style={{
marginTop: 8, fontSize: 12,
background: "#FEF3C7", color: "#92400E",
border: "1px solid #FDE68A",
borderRadius: "var(--radius-md)",
padding: "0.5rem 0.75rem",
}}>
يحتوي هذا الدور على {permCount} صلاحية مرتبطة، سيتم إلغاء ارتباطها تلقائياً.
</p>
)}
<p style={{ marginTop: 6, fontSize: 12, color: "#DC2626" }}>
لا يمكن التراجع عن هذا الإجراء.
</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>
);
}

View File

@@ -0,0 +1,375 @@
"use client";
import { useState } from "react";
import { Alert, Spinner } from "../UI";
import type { Role, Permission } from "../../../services/role.service";
const MODULE_LABELS: Record<string, string> = {
Role: "الأدوار",
User: "المستخدمون",
Branch: "الفروع",
Car: "المركبات",
CarImage: "صور المركبات",
Driver: "السائقون",
Order: "الطلبات",
Trip: "الرحلات",
Client: "العملاء",
Permission: "الصلاحيات",
Audit: "سجل التدقيق",
Dashboard: "لوحة التحكم",
Maintenance: "الصيانة",
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
function groupByModule(
permissions: Permission[],
): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
(acc[p.module || "أخرى"] ??= []).push(p);
return acc;
}, {});
}
interface RoleDetailModalProps {
role: Role;
allPermissions: Permission[];
onClose: () => void;
onSave: (roleId: string, permissionIds: string[]) => Promise<boolean>;
}
export function RoleDetailModal({
role,
allPermissions,
onClose,
onSave,
}: RoleDetailModalProps) {
const currentIds = role.permissions?.map((rp) => rp.permission.id) ?? [];
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const [saved, setSaved] = useState(false);
const grouped = groupByModule(allPermissions);
const activeSet = new Set(currentIds);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
setSaved(false);
};
const handleSave = async () => {
setSaving(true);
setApiError("");
const ok = await onSave(role.id, [...selected]);
setSaving(false);
if (ok) {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
setApiError("حدث خطأ أثناء حفظ الصلاحيات. حاول مرة أخرى.");
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="role-detail-title"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
background: "rgba(15,23,42,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
}}
>
<div
onClick={(e) => e.stopPropagation()}
dir="rtl"
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",
display: "flex",
flexDirection: "column",
maxHeight: "90vh",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
flexShrink: 0,
}}
>
<div>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
تفاصيل الدور
</p>
<h2
id="role-detail-title"
style={{
fontSize: 17,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: "4px 0 0",
}}
>
{role.name}
</h2>
{role.description && (
<p
style={{
fontSize: 12,
color: "var(--color-text-muted)",
marginTop: 4,
}}
>
{role.description}
</p>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
style={{
width: 34,
height: 34,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer",
fontSize: 18,
color: "var(--color-text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
×
</button>
</div>
{/* Body */}
<div
style={{
padding: "1.5rem",
overflowY: "auto",
flex: 1,
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{apiError && (
<Alert
type="error"
message={apiError}
onClose={() => setApiError("")}
/>
)}
{saved && (
<Alert
type="success"
message="تم حفظ التغييرات بنجاح."
onClose={() => setSaved(false)}
/>
)}
<p style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{selected.size} من {allPermissions.length} صلاحية مفعّلة لهذا الدور
</p>
<div
style={{
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
}}
>
{Object.entries(grouped).map(([mod, perms], idx) => (
<div
key={mod}
style={{
borderBottom:
idx < Object.keys(grouped).length - 1
? "1px solid var(--color-border)"
: "none",
}}
>
<div
style={{
padding: "0.625rem 1rem",
background: "var(--color-surface-muted)",
fontSize: 12,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
{moduleLabel(mod)}
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(180px, 1fr))",
gap: "0.5rem",
padding: "0.75rem 1rem",
}}
>
{perms.map((perm) => {
const checked = selected.has(perm.id);
const wasActive = activeSet.has(perm.id);
return (
<label
key={perm.id}
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
padding: "0.5rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked
? "#EFF6FF"
: "var(--color-surface-muted)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(perm.id)}
style={{
width: 14,
height: 14,
marginTop: 1,
cursor: "pointer",
accentColor: "#2563EB",
}}
/>
<div>
<p
style={{
fontSize: 11,
fontWeight: 600,
color: checked
? "#1D4ED8"
: "var(--color-text-primary)",
margin: 0,
}}
>
{perm.name}
</p>
<span
style={{
fontSize: 9,
fontWeight: 700,
padding: "1px 6px",
borderRadius: "var(--radius-full)",
color: wasActive ? "#166534" : "#991B1B",
background: wasActive ? "#DCFCE7" : "#FEF2F2",
}}
>
{wasActive ? "نشطة حالياً" : "غير نشطة"}
</span>
</div>
</label>
);
})}
</div>
</div>
))}
</div>
</div>
{/* Footer */}
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
flexShrink: 0,
}}
>
<button
type="button"
onClick={onClose}
disabled={saving}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: saving ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
<button
type="button"
onClick={handleSave}
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 ? "جارٍ الحفظ…" : "حفظ التغييرات"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,510 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Alert, Spinner } from "../UI";
import type { Role, RoleFormData, Permission } from "../../../services/role.service";
// ── 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,
};
// ── Module label map (Arabic) ─────────────────────────────────────────────────
const MODULE_LABELS: Record<string, string> = {
Role: "الأدوار",
User: "المستخدمون",
Branch: "الفروع",
Car: "المركبات",
CarImage: "صور المركبات",
Driver: "السائقون",
Order: "الطلبات",
Trip: "الرحلات",
Client: "العملاء",
Permission: "الصلاحيات",
Audit: "سجل التدقيق",
Dashboard: "لوحة التحكم",
Maintenance: "الصيانة",
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Group permissions by their module field */
function groupByModule(permissions: Permission[]): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
const key = p.module || "أخرى";
if (!acc[key]) acc[key] = [];
acc[key].push(p);
return acc;
}, {});
}
interface FormErrors {
name?: string;
}
function validate(name: string): FormErrors {
const e: FormErrors = {};
if (!name.trim()) e.name = "اسم الدور مطلوب";
else if (name.trim().length < 2) e.name = "الاسم يجب أن يكون حرفين على الأقل";
return e;
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface RoleFormModalProps {
editRole: Role | null; // null = create mode
permissions: Permission[]; // all available permissions
onClose: () => void;
onSubmit: (data: RoleFormData, currentPermIds: string[]) => Promise<boolean>;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: RoleFormModalProps) {
const isNew = editRole === null;
// Current permission IDs attached to the role being edited
const currentPermIds = editRole?.permissions?.map(rp => rp.permission.id) ?? [];
const [name, setName] = useState(editRole?.name ?? "");
const [description, setDescription] = useState(editRole?.description ?? "");
const [selectedPerms, setSelectedPerms] = useState<Set<string>>(new Set(currentPermIds));
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const [searchPerm, setSearchPerm] = useState("");
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
const firstInputRef = useRef<HTMLInputElement>(null);
const grouped = groupByModule(permissions);
// Auto-expand modules that have selected permissions or match search
useEffect(() => {
if (editRole) {
const modulesWithSelected = new Set<string>();
permissions.forEach(p => {
if (currentPermIds.includes(p.id)) modulesWithSelected.add(p.module);
});
setExpandedModules(modulesWithSelected);
}
firstInputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Keyboard: Escape closes
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
// ── Checkbox helpers ───────────────────────────────────────────────────────
const togglePerm = (id: string) => {
setSelectedPerms(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleModule = (module: string) => {
const modulePerms = (grouped[module] ?? []).map(p => p.id);
const allSelected = modulePerms.every(id => selectedPerms.has(id));
setSelectedPerms(prev => {
const next = new Set(prev);
if (allSelected) modulePerms.forEach(id => next.delete(id));
else modulePerms.forEach(id => next.add(id));
return next;
});
};
const toggleExpandModule = (module: string) => {
setExpandedModules(prev => {
const next = new Set(prev);
if (next.has(module)) next.delete(module);
else next.add(module);
return next;
});
};
const selectAll = () => setSelectedPerms(new Set(permissions.map(p => p.id)));
const clearAll = () => setSelectedPerms(new Set());
// ── Submit ─────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errs = validate(name);
if (Object.keys(errs).length) { setErrors(errs); return; }
setSaving(true);
setApiError("");
const ok = await onSubmit(
{ name: name.trim(), description: description.trim(), permissionIds: [...selectedPerms] },
currentPermIds,
);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
};
// ── Filter permissions by search ───────────────────────────────────────────
const filteredGrouped = Object.entries(grouped).reduce<Record<string, Permission[]>>((acc, [mod, perms]) => {
if (!searchPerm.trim()) {
acc[mod] = perms;
} else {
const q = searchPerm.trim().toLowerCase();
const filtered = perms.filter(p =>
p.name.toLowerCase().includes(q) ||
p.slug.toLowerCase().includes(q) ||
p.module.toLowerCase().includes(q)
);
if (filtered.length) acc[mod] = filtered;
}
return acc;
}, {});
const totalSelected = selectedPerms.size;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="role-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<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",
display: "flex", flexDirection: "column",
maxHeight: "90vh",
}}
>
{/* ── Modal header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
flexShrink: 0,
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
{isNew ? "إضافة دور" : "تعديل دور"}
</p>
<h2 id="role-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "دور جديد" : editRole.name}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)", cursor: "pointer",
fontSize: 18, color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── Scrollable body ── */}
<form
onSubmit={handleSubmit}
noValidate
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.25rem", overflowY: "auto", flex: 1 }}
>
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
{/* Name field */}
<label style={S.label}>
اسم الدور *
<input
ref={firstInputRef}
value={name}
onChange={e => { setName(e.target.value); if (errors.name) setErrors({}); }}
placeholder="مدير النظام"
autoComplete="off"
dir="rtl"
style={{
...S.input,
...(errors.name ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
}}
/>
{errors.name && <span style={S.errorText}>{errors.name}</span>}
</label>
{/* Description field */}
<label style={S.label}>
الوصف (اختياري)
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="وصف مختصر لمهام هذا الدور…"
rows={2}
dir="rtl"
style={{
...S.input,
height: "auto",
padding: "0.5rem 0.75rem",
resize: "vertical",
minHeight: 60,
}}
/>
</label>
{/* ── Permissions section ── */}
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<div>
<p style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-secondary)", margin: 0 }}>
الصلاحيات
</p>
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 2 }}>
{totalSelected} من {permissions.length} صلاحية محددة
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={selectAll}
style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "var(--color-brand-50)", border: "1px solid var(--color-brand-200)", borderRadius: "var(--radius-md)", padding: "4px 10px", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
تحديد الكل
</button>
<button type="button" onClick={clearAll}
style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", background: "var(--color-surface-muted)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)", padding: "4px 10px", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء الكل
</button>
</div>
</div>
{/* Permission search */}
<div style={{ position: "relative", marginBottom: "0.75rem" }}>
<svg style={{ position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)", width: 14, height: 14, 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={searchPerm}
onChange={e => setSearchPerm(e.target.value)}
dir="rtl"
style={{
...S.input,
paddingRight: 30, height: 36, fontSize: 12,
}}
/>
</div>
{/* Grouped permission modules */}
{permissions.length === 0 ? (
<div style={{ textAlign: "center", padding: "2rem", fontSize: 13, color: "var(--color-text-muted)" }}>
جارٍ تحميل الصلاحيات
</div>
) : Object.keys(filteredGrouped).length === 0 ? (
<div style={{ textAlign: "center", padding: "1.5rem", fontSize: 12, color: "var(--color-text-muted)" }}>
لا توجد نتائج لـ &quot;{searchPerm}&quot;
</div>
) : (
<div style={{
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
}}>
{Object.entries(filteredGrouped).map(([mod, perms], idx) => {
const allChecked = perms.every(p => selectedPerms.has(p.id));
const someChecked = !allChecked && perms.some(p => selectedPerms.has(p.id));
const isExpanded = expandedModules.has(mod) || !!searchPerm;
const checkedCount = perms.filter(p => selectedPerms.has(p.id)).length;
return (
<div key={mod} style={{ borderBottom: idx < Object.keys(filteredGrouped).length - 1 ? "1px solid var(--color-border)" : "none" }}>
{/* Module header row */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.625rem 1rem",
background: isExpanded ? "var(--color-surface-muted)" : "var(--color-surface)",
cursor: "pointer",
transition: "background 150ms",
}}>
{/* Module checkbox (select/deselect all in module) */}
<input
type="checkbox"
checked={allChecked}
ref={el => { if (el) el.indeterminate = someChecked; }}
onChange={() => toggleModule(mod)}
onClick={e => e.stopPropagation()}
style={{ width: 15, height: 15, cursor: "pointer", flexShrink: 0, accentColor: "#2563EB" }}
/>
{/* Module name */}
<button
type="button"
onClick={() => toggleExpandModule(mod)}
style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "space-between",
background: "none", border: "none", cursor: "pointer", padding: 0,
fontFamily: "var(--font-sans)",
}}
>
<span style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-primary)" }}>
{moduleLabel(mod)}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{checkedCount > 0 && (
<span style={{
fontSize: 10, fontWeight: 700,
color: "#2563EB",
background: "var(--color-brand-50)",
border: "1px solid var(--color-brand-200)",
borderRadius: "var(--radius-full)",
padding: "1px 7px",
}}>
{checkedCount}/{perms.length}
</span>
)}
<svg
style={{
width: 14, height: 14,
color: "var(--color-text-muted)",
transform: isExpanded ? "rotate(180deg)" : "none",
transition: "transform 200ms",
}}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
>
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
</button>
</div>
{/* Permissions grid */}
{isExpanded && (
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
gap: "0.5rem",
padding: "0.75rem 1rem 0.875rem",
background: "var(--color-surface)",
}}>
{perms.map(perm => {
const checked = selectedPerms.has(perm.id);
return (
<label
key={perm.id}
style={{
display: "flex", alignItems: "flex-start", gap: 8,
padding: "0.5rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked ? "#EFF6FF" : "var(--color-surface-muted)",
cursor: "pointer",
transition: "all 150ms",
userSelect: "none",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => togglePerm(perm.id)}
style={{ width: 14, height: 14, marginTop: 1, cursor: "pointer", flexShrink: 0, accentColor: "#2563EB" }}
/>
<div>
<p style={{ fontSize: 11, fontWeight: 600, color: checked ? "#1D4ED8" : "var(--color-text-primary)", margin: 0, lineHeight: 1.4 }}>
{perm.name}
</p>
<p style={{ fontSize: 10, color: "var(--color-text-muted)", margin: "2px 0 0", fontFamily: "var(--font-mono)" }}>
{perm.slug}
</p>
</div>
</label>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* ── Action buttons ── */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.25rem", flexShrink: 0 }}>
<button
type="button"
onClick={onClose}
disabled={saving}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: saving ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="submit"
disabled={saving}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13, fontWeight: 700,
color: "#FFF",
cursor: saving ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إنشاء الدور" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import { Spinner } from "../UI";
import type { Role } from "../../../services/role.service";
// ── 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)",
};
// ── Sub-components ─────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
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>
);
}
// ── Props ──────────────────────────────────────────────────────────────────────
interface RoleTableProps {
roles: Role[];
loading: boolean;
search: string;
page: number;
pages: number;
onEdit: (role: Role) => void;
onDelete: (role: Role) => void;
onView: (role: Role) => void;
onAddFirst: () => void;
onPageChange: (p: number) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function RoleTable({
roles, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange,
}: RoleTableProps) {
return (
<div style={cardStyle}>
{/* Column headers */}
<div
dir="rtl"
style={{
display: "grid",
gridTemplateColumns: "2fr 2.5fr 1fr 1fr 80px",
...thStyle,
}}
>
<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>
) : roles.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 }}>
{roles.map((role, i) => {
const permCount = role.permissions?.length ?? 0;
// Show up to 3 permission module badges
const modules = [...new Set(role.permissions?.map(rp => rp.permission.module) ?? [])].slice(0, 3);
const extra = (role.permissions?.length ?? 0) - modules.length;
return (
<li
key={role.id}
onClick={() => onView(role)}
style={{
display: "grid",
gridTemplateColumns: "2fr 2.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",
cursor: "pointer",
fontSize: 13,
}}
>
{/* Role name + ID */}
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
{role.name}
</p>
{role.id && (
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>
{role.id.slice(0, 8)}
</p>
)}
</div>
{/* Permissions preview */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.25rem", alignItems: "center" }}>
{permCount === 0 ? (
<span style={{ fontSize: 11, color: "var(--color-text-hint)" }}>لا توجد صلاحيات</span>
) : (
<>
{modules.map(mod => (
<span key={mod} style={{
fontSize: 10, fontWeight: 600,
color: "#374151",
background: "#F3F4F6",
border: "1px solid #E5E7EB",
borderRadius: "var(--radius-full)",
padding: "2px 7px",
}}>
{mod}
</span>
))}
{extra > 0 && (
<span style={{
fontSize: 10, fontWeight: 700,
color: "#2563EB",
background: "var(--color-brand-50)",
border: "1px solid var(--color-brand-200)",
borderRadius: "var(--radius-full)",
padding: "2px 7px",
}}>
+{extra}
</span>
)}
<span style={{ fontSize: 10, color: "var(--color-text-muted)", marginRight: 2 }}>
({permCount} صلاحية)
</span>
</>
)}
</div>
{/* Status badge */}
<StatusBadge active={role.isActive !== false} />
{/* Created date */}
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(role.createdAt).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
})}
</span>
{/* Actions */}
<div style={{ display: "flex", justifyContent: "center", gap: 6 }}>
<IconBtn
title={`تعديل ${role.name}`}
color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE"
onClick={() => onEdit(role)}
>
<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={`حذف ${role.name}`}
color="#DC2626" bg="#FEF2F2" borderColor="#FECACA"
onClick={() => onDelete(role)}
>
<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>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import type { RoleNotification } from "../../../hooks/useRole";
interface RoleToastProps {
notification: RoleNotification | null;
}
export function RoleToast({ notification }: RoleToastProps) {
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>
);
}

View File

@@ -0,0 +1,5 @@
export { RoleFormModal } from "./RoleFormModal";
export { RoleTable } from "./RoleTable";
export { RoleDetailModal } from "./RoleDetailModal";
export { DeleteRoleModal } from "./DeleteRoleModal";
export { RoleToast } from "./RoleToast";

216
src/hooks/useCars.ts Normal file
View File

@@ -0,0 +1,216 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { carService } from "@/src/services/car.service";
import { get } from "@/src/services/api";
import type {
Car,
CarImage,
CarListResponse,
CreateCarPayload,
ImageStage,
ToastMsg,
UpdateCarPayload,
} from "../types/car";
// ── useCars ───────────────────────────────────────────────────────────────────
// Manages the paginated car list for the main page.
export function useCars(page: number, search: string) {
const [cars, setCars] = useState<Car[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const loadCars = useCallback(() => {
const token = getStoredToken();
setLoading(true);
setError(null);
const query = `?page=${page}&limit=12${search ? `&search=${encodeURIComponent(search)}` : ""}`;
get<CarListResponse>(`cars${query}`, token)
.then((res) => {
const payload = (res as unknown as { data: CarListResponse["data"] }).data ?? res;
setCars((payload as CarListResponse["data"]).data ?? []);
setTotal((payload as CarListResponse["data"]).meta?.total ?? 0);
setPages((payload as CarListResponse["data"]).meta?.pages ?? 1);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [page, search]);
useEffect(() => { loadCars(); }, [loadCars]);
// Optimistic removal after delete
const removeCar = useCallback((id: string) => {
setCars(prev => prev.filter(c => c.id !== id));
setTotal(prev => Math.max(0, prev - 1));
}, []);
return { cars, loading, error, total, pages, loadCars, removeCar, setError };
}
// ── useCarDetail ──────────────────────────────────────────────────────────────
// Fetches a single car by ID — used in CarDetailPanel.
export function useCarDetail(carId: string) {
const [car, setCar] = useState<Car | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const token = getStoredToken();
setLoading(true);
setError(null);
carService
.getById(carId, token)
.then(res => setCar((res as unknown as { data: Car }).data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [carId]);
return { car, loading, error };
}
// ── useCarMutations ───────────────────────────────────────────────────────────
// Create, update, and delete operations — used in the main page.
interface UseCarMutationsOptions {
onSuccess: (msg: string) => void;
onError: (msg: string) => void;
onDeleted: (id: string) => void;
getEditTarget: () => Car | null;
}
export function useCarMutations({
onSuccess,
onError,
onDeleted,
getEditTarget,
}: UseCarMutationsOptions) {
const [deleting, setDeleting] = useState(false);
const handleFormSubmit = useCallback(async (
payload: CreateCarPayload | UpdateCarPayload,
isNew: boolean,
): Promise<boolean> => {
const token = getStoredToken();
try {
if (isNew) {
await carService.create(payload as CreateCarPayload, token);
onSuccess("تم إضافة المركبة بنجاح.");
} else {
const editTarget = getEditTarget();
if (!editTarget) return false;
await carService.update(editTarget.id, payload as UpdateCarPayload, token);
onSuccess("تم تحديث بيانات المركبة.");
}
return true;
} catch (err: unknown) {
onError(err instanceof Error ? err.message : "فشلت العملية.");
return false;
}
}, [onSuccess, onError, getEditTarget]);
const handleDeleteConfirm = useCallback(async (target: Car) => {
setDeleting(true);
const token = getStoredToken();
try {
await carService.delete(target.id, token);
onDeleted(target.id);
onSuccess(`تم حذف ${target.manufacturer} ${target.model} بنجاح.`);
} catch (err: unknown) {
onError(err instanceof Error ? err.message : "فشل الحذف.");
} finally {
setDeleting(false);
}
}, [onSuccess, onError, onDeleted]);
return { deleting, handleFormSubmit, handleDeleteConfirm };
}
// ── useToast ──────────────────────────────────────────────────────────────────
// Simple toast notification state.
export function useToast(duration = 3500) {
const [toast, setToast] = useState<ToastMsg | null>(null);
const notify = useCallback((t: ToastMsg) => {
setToast(t);
setTimeout(() => setToast(null), duration);
}, [duration]);
return { toast, notify };
}
// ── useCarImages ──────────────────────────────────────────────────────────────
// Manages gallery images — used in CarImageGallery.
export function useCarImages(carId: string, sortBy: "asc" | "desc") {
const [images, setImages] = useState<CarImage[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchImages = useCallback(async () => {
setLoading(true);
setError(null);
try {
const token = getStoredToken();
const res = await carService.getImages(carId, token, { sortBy });
const raw = (res as unknown as { data: CarImage[] }).data ?? [];
setImages(raw);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل الصور");
} finally {
setLoading(false);
}
}, [carId, sortBy]);
useEffect(() => { fetchImages(); }, [fetchImages]);
const uploadImages = useCallback(async (files: File[], stage: ImageStage) => {
setUploading(true);
setError(null);
try {
const token = getStoredToken();
await carService.uploadImages(carId, files, stage, token);
await fetchImages();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "فشل رفع الصور");
} finally {
setUploading(false);
}
}, [carId, fetchImages]);
const deleteImage = useCallback(async (imageId: string) => {
setDeleting(imageId);
setError(null);
try {
const token = getStoredToken();
await carService.deleteImage(imageId, token);
setImages(prev => prev.filter(img => img.id !== imageId));
return true;
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "فشل حذف الصورة");
return false;
} finally {
setDeleting(null);
}
}, []);
return {
images,
loading,
uploading,
deleting,
error,
setError,
fetchImages,
uploadImages,
deleteImage,
};
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { clientAddressService } from "@/src/services/clientAddress.service";
import type {
ClientAddress,
ClientAddressFormData,
AddressTableAction,
AddressTableState,
} from "@/src/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,
};
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { clientService } from "@/src/services/client.service";
import type { Order } from "@/src/types/order";
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
src/hooks/useClients.ts Normal file
View File

@@ -0,0 +1,191 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { clientService } from "@/src/services/client.service";
import type {
Client,
ClientFormData,
ClientTableAction,
ClientTableState,
} from "@/src/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),
};
}

View File

@@ -0,0 +1,49 @@
// hooks/useDashboardSummary.ts
"use client";
import { useEffect, useState } from "react";
import { dashboardService } from "@/src/services/dashboard.service";
import { getStoredToken } from "@/src/lib/auth";
import type { DashboardSummary } from "@/src/types/dashboard";
interface State {
data: DashboardSummary | null;
error: string | null;
loading: boolean;
}
/**
* Fetches the admin dashboard summary.
* Redirects to /login if no token is present (handled by the caller).
*
* @example
* const { data, loading, error } = useDashboardSummary();
*/
export function useDashboardSummary(): State {
const [state, setState] = useState<State>({
data: null, error: null, loading: true,
});
useEffect(() => {
let cancelled = false;
const token = getStoredToken();
if (!token) {
setState({ data: null, error: "Unauthenticated", loading: false });
return;
}
dashboardService
.getSummary(token)
.then(res => {
if (!cancelled) setState({ data: res.data, error: null, loading: false });
})
.catch((err: Error) => {
if (!cancelled) setState({ data: null, error: err.message, loading: false });
});
return () => { cancelled = true; };
}, []);
return state;
}

211
src/hooks/useDriver.ts Normal file
View File

@@ -0,0 +1,211 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { driverService } from "@/src/services/driver.service";
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
import type { ToastNotification } from "@/src/Components/UI";
export type DriverNotification = ToastNotification;
// ── 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: "UPDATE"; driver: Driver }
| { 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) };
// Instantly reflect updated driver in the list without a full reload
case "UPDATE":
return { ...s, drivers: s.drivers.map((d) => (d.id === a.driver.id ? a.driver : d)) };
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<ToastNotification | null>(null);
// Show a toast for 4 seconds then auto-dismiss
const notify = useCallback((n: ToastNotification) => {
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);
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: "تعذّر تحميل بيانات السائقين. يرجى المحاولة مجدداً." });
}
}, []);
useEffect(() => {
loadDrivers(page, search);
}, [page, search, loadDrivers]);
// ── Create ────────────────────────────────────────────────────────────────
const createDriver = useCallback(
async (
payload: CreateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
): Promise<boolean> => {
try {
const token = getStoredToken();
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
if (hasFiles) {
const res = await driverService.createWithImages(payload, token);
const created = (res as unknown as { data: Driver }).data;
if (created?.id) {
await driverService.getById(created.id, token).catch(() => null);
}
} else {
await driverService.create(payload, token);
}
notify({ type: "success", message: "تم إضافة السائق بنجاح." });
await loadDrivers(page, search);
return true;
} catch (err) {
// ✅ بنعرض رسالة الخطأ كـ toast برضو، مش بس نرميها لفوق
const message = err instanceof Error ? err.message : "تعذّر إضافة السائق. يرجى المحاولة لاحقاً.";
notify({ type: "error", message });
throw err; // يفضل يترمي عشان DriverFormModal يعرضه جوه المودال كمان لو محتاج
}
},
[notify, loadDrivers, page, search],
);
// ── Update ────────────────────────────────────────────────────────────────
const updateDriver = useCallback(
async (
id: string,
payload: UpdateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
): Promise<boolean> => {
try {
const token = getStoredToken();
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
let updatedDriver: Driver;
if (hasFiles) {
await driverService.updateWithImages(id, payload, token);
const fresh = await driverService.getById(id, token);
updatedDriver = (fresh as unknown as { data: Driver }).data;
} else {
const res = await driverService.update(id, payload, token);
updatedDriver = (res as unknown as { data: Driver }).data;
}
dispatch({ type: "UPDATE", driver: updatedDriver });
notify({ type: "success", message: "تم تحديث بيانات السائق بنجاح." });
return true;
} catch (err) {
// ✅ نفس الفكرة هنا — أي فشل في التحديث يبان كـ toast أحمر
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق. يرجى المحاولة لاحقاً.";
notify({ type: "error", message });
throw err;
}
},
[notify],
);
// ── 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) {
const message = err instanceof Error ? err.message : "تعذّر حذف السائق.";
notify({ type: "error", 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" }),
createDriver,
updateDriver,
deleteDriver,
notification,
reload: () => loadDrivers(page, search),
};
}

181
src/hooks/useOrder.ts Normal file
View File

@@ -0,0 +1,181 @@
"use client";
import { useCallback, useEffect, useReducer } from "react";
import { orderService } from "@/src/services/order.service";
import type {
Order,
CreateOrderPayload,
UpdateOrderPayload,
UpdateOrderStatusPayload,
} from "@/src/types/order";
// ── State ─────────────────────────────────────────────────
interface State {
orders: Order[];
total: number;
page: number;
totalPages: number;
loading: boolean;
submitting: boolean;
error: string | null;
}
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: { orders: Order[]; total: number; totalPages: number; page: number } }
| { type: "FETCH_ERROR"; error: string }
| { type: "SUBMIT_START" }
| { type: "SUBMIT_SUCCESS"; order: Order; isNew: boolean }
| { type: "SUBMIT_ERROR"; error: string }
| { type: "DELETE_SUCCESS"; id: string }
| { type: "CLEAR_ERROR" };
const initialState: State = {
orders: [],
total: 0,
page: 1,
totalPages: 1,
loading: false,
submitting: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, loading: true, error: null };
case "FETCH_SUCCESS":
return {
...state,
loading: false,
orders: action.payload.orders,
total: action.payload.total,
totalPages: action.payload.totalPages,
page: action.payload.page,
};
case "FETCH_ERROR":
return { ...state, loading: false, error: action.error };
case "SUBMIT_START":
return { ...state, submitting: true, error: null };
case "SUBMIT_SUCCESS":
return {
...state,
submitting: false,
orders: action.isNew
? [action.order, ...state.orders]
: state.orders.map((o) => (o.id === action.order.id ? action.order : o)),
};
case "SUBMIT_ERROR":
return { ...state, submitting: false, error: action.error };
case "DELETE_SUCCESS":
return { ...state, orders: state.orders.filter((o) => o.id !== action.id) };
case "CLEAR_ERROR":
return { ...state, error: null };
default:
return state;
}
}
// ── Hook ──────────────────────────────────────────────────
export function useOrders(autoFetch = true) {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchOrders = useCallback(
async (page = 1, limit = 20) => {
dispatch({ type: "FETCH_START" });
try {
const res = await orderService.getAll({ page, limit });
const body = (res as unknown as { data: { data: Order[]; meta: { total: number; totalPages: number; page: number } } }).data;
dispatch({
type: "FETCH_SUCCESS",
payload: {
orders: body.data,
total: body.meta.total,
totalPages: body.meta.totalPages,
page: body.meta.page,
},
});
} catch (err: unknown) {
dispatch({
type: "FETCH_ERROR",
error: err instanceof Error ? err.message : "Failed to load orders",
});
}
},
[],
);
const createOrder = useCallback(async (payload: CreateOrderPayload) => {
dispatch({ type: "SUBMIT_START" });
try {
const res = await orderService.create(payload);
const order = (res as unknown as { data: Order }).data;
dispatch({ type: "SUBMIT_SUCCESS", order, isNew: true });
return order;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to create order";
dispatch({ type: "SUBMIT_ERROR", error: msg });
throw new Error(msg);
}
}, []);
const updateOrder = useCallback(
async (id: string, payload: UpdateOrderPayload) => {
dispatch({ type: "SUBMIT_START" });
try {
const res = await orderService.update(id, payload);
const order = (res as unknown as { data: Order }).data;
dispatch({ type: "SUBMIT_SUCCESS", order, isNew: false });
return order;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to update order";
dispatch({ type: "SUBMIT_ERROR", error: msg });
throw new Error(msg);
}
},
[],
);
const updateStatus = useCallback(
async (id: string, payload: UpdateOrderStatusPayload) => {
dispatch({ type: "SUBMIT_START" });
try {
const res = await orderService.updateStatus(id, payload);
const order = (res as unknown as { data: Order }).data;
dispatch({ type: "SUBMIT_SUCCESS", order, isNew: false });
return order;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to update status";
dispatch({ type: "SUBMIT_ERROR", error: msg });
throw new Error(msg);
}
},
[],
);
const deleteOrder = useCallback(async (id: string) => {
try {
await orderService.delete(id);
dispatch({ type: "DELETE_SUCCESS", id });
} catch (err: unknown) {
dispatch({
type: "FETCH_ERROR",
error: err instanceof Error ? err.message : "Failed to delete order",
});
}
}, []);
useEffect(() => {
if (autoFetch) fetchOrders();
}, [autoFetch, fetchOrders]);
return {
...state,
fetchOrders,
createOrder,
updateOrder,
updateStatus,
deleteOrder,
clearError: () => dispatch({ type: "CLEAR_ERROR" }),
};
}

284
src/hooks/useRole.ts Normal file
View File

@@ -0,0 +1,284 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { roleService } from "@/src/services/role.service";
import type { Role, RoleFormData, Permission } from "@/src/services/role.service";
// ── Notification ──────────────────────────────────────────────────────────────
export interface RoleNotification {
type: "success" | "error";
message: string;
}
// ── Table state / reducer ─────────────────────────────────────────────────────
interface TableState {
roles: Role[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type TableAction =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; roles: Role[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; role: Role }
| { type: "UPDATE"; role: Role }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };
function tableReducer(s: TableState, a: TableAction): TableState {
switch (a.type) {
case "LOAD_START":
return { ...s, loading: true, error: null };
case "LOAD_OK":
return {
...s,
loading: false,
roles: a.roles,
total: a.total,
pages: a.pages,
};
case "LOAD_ERR":
return { ...s, loading: false, error: a.error };
case "ADD":
return { ...s, roles: [a.role, ...s.roles] };
case "UPDATE":
return {
...s,
roles: s.roles.map((r) => (r.id === a.role.id ? a.role : r)),
};
case "DELETE":
return { ...s, roles: s.roles.filter((r) => r.id !== a.id) };
case "CLEAR_ERR":
return { ...s, error: null };
default:
return s;
}
}
const initialState: TableState = {
roles: [],
loading: true,
total: 0,
pages: 1,
error: null,
};
// ── Main hook ─────────────────────────────────────────────────────────────────
export function useRoles() {
const [state, dispatch] = useReducer(tableReducer, initialState);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [notification, setNotification] = useState<RoleNotification | null>(
null,
);
const notify = useCallback((n: RoleNotification) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
}, []);
// ── Fetch roles ─────────────────────────────────────────────────────────────
const loadRoles = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const res = await roleService.getAll(p, q, token);
const payload =
(
res as unknown as {
data: {
data: Role[];
meta?: { total: number; pages: number };
pagination?: { total: number; pages: number };
};
}
).data ?? res;
dispatch({
type: "LOAD_OK",
roles: payload.data ?? [],
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
});
} catch {
dispatch({
type: "LOAD_ERR",
error: "تعذّر تحميل بيانات الأدوار. يرجى المحاولة مجدداً.",
});
}
}, []);
// ── Load permissions on mount ────────────────────────────────────────────────
useEffect(() => {
const token = getStoredToken();
roleService
.getPermissions(token)
.then((res) => {
//console.log("permissions response:", JSON.stringify(res, null, 2));
const raw = (
res as unknown as { data: { premissions: { data: Permission[] } } }
).data;
setPermissions(raw?.premissions?.data ?? []);
})
.catch(() => {});
}, []);
// ── Reload when page / search changes ────────────────────────────────────────
useEffect(() => {
loadRoles(page, search);
}, [page, search, loadRoles]);
// ── CRUD actions ─────────────────────────────────────────────────────────────
const createRole = useCallback(
async (data: RoleFormData): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await roleService.create(data, token);
const role = (res as unknown as { data: Role }).data;
// Assign permissions sequentially (backend handles individually)
for (const pid of data.permissionIds) {
try {
await roleService.assignPermission(role.id, pid, token);
} catch {
/* non-fatal: skip individual failures */
}
}
// Re-fetch to get fresh data with permissions attached
await loadRoles(page, search);
notify({
type: "success",
message: "تم إنشاء الدور وتعيين الصلاحيات بنجاح.",
});
return true;
} catch (err) {
notify({
type: "error",
message: err instanceof Error ? err.message : "تعذّر إنشاء الدور.",
});
return false;
}
},
[page, search, loadRoles, notify],
);
const updateRole = useCallback(
async (
id: string,
data: RoleFormData,
currentPermIds: string[],
): Promise<boolean> => {
try {
const token = getStoredToken();
// Update basic fields
await roleService.update(id, data, token);
// Diff permissions: add new, remove removed
const toAdd = data.permissionIds.filter(
(p) => !currentPermIds.includes(p),
);
const toRemove = currentPermIds.filter(
(p) => !data.permissionIds.includes(p),
);
for (const pid of toAdd) {
try {
await roleService.assignPermission(id, pid, token);
} catch {
/* skip */
}
}
for (const pid of toRemove) {
try {
await roleService.removePermission(id, pid, token);
} catch {
/* skip */
}
}
await loadRoles(page, search);
notify({
type: "success",
message: "تم تحديث الدور والصلاحيات بنجاح.",
});
return true;
} catch (err) {
notify({
type: "error",
message: err instanceof Error ? err.message : "تعذّر تحديث الدور.",
});
return false;
}
},
[page, search, loadRoles, notify],
);
const deleteRole = useCallback(
async (id: string): Promise<boolean> => {
try {
const token = getStoredToken();
await roleService.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],
);
const updateRolePermissionsBulk = useCallback(
async (roleId: string, permissionIds: string[]): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await roleService.setPermissions(roleId, permissionIds, token);
const updated = (res as unknown as { data: Role }).data;
dispatch({ type: "UPDATE", role: updated });
notify({ type: "success", message: "تم تحديث صلاحيات الدور بنجاح." });
return true;
} catch (err) {
notify({
type: "error",
message: err instanceof Error ? err.message : "تعذّر تحديث صلاحيات الدور.",
});
return false;
}
},
[notify],
);
const handleSearch = useCallback((q: string) => {
setSearch(q);
setPage(1);
}, []);
return {
...state,
permissions,
page,
search,
setPage,
handleSearch,
clearError: () => dispatch({ type: "CLEAR_ERR" }),
createRole,
updateRole,
deleteRole,
updateRolePermissionsBulk,
notification,
};
}

227
src/hooks/useTrip.ts Normal file
View File

@@ -0,0 +1,227 @@
"use client";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { tripService } from "@/src/services/trip.service";
import type { Trip, TripStatus, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
// ── Notification type ──────────────────────────────────────────────────────
export interface TripNotification {
type: "success" | "error";
message: string;
}
// ── API error extractor ────────────────────────────────────────────────────
// Walks common API error shapes to pull the real backend message.
// Falls back to the provided default only when nothing useful is found.
function extractApiMessage(err: unknown, fallback: string): string {
if (typeof err === "string" && err.trim()) return err.trim();
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
// Shape: { response: { data: { message: string } } } (axios-style)
const responseData = (e["response"] as Record<string, unknown> | undefined)?.["data"];
if (responseData && typeof responseData === "object") {
const rd = responseData as Record<string, unknown>;
if (typeof rd["message"] === "string" && rd["message"].trim()) return rd["message"];
if (Array.isArray(rd["message"])) return (rd["message"] as string[]).join(" — ");
if (typeof rd["error"] === "string" && rd["error"].trim()) return rd["error"];
}
// Shape: { message: string } (plain Error / fetch wrapper)
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"];
}
return fallback;
}
// ── Table state / reducer ──────────────────────────────────────────────────
interface TableState {
trips: Trip[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type TableAction =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; trips: Trip[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; trip: Trip }
| { type: "UPDATE"; trip: Trip }
| { 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, trips: a.trips, total: a.total, pages: a.pages };
case "LOAD_ERR": return { ...s, loading: false, error: a.error };
case "ADD": return { ...s, trips: [a.trip, ...s.trips], total: s.total + 1 };
case "UPDATE": return { ...s, trips: s.trips.map(t => (t.id === a.trip.id ? a.trip : t)) };
case "DELETE": return { ...s, trips: s.trips.filter(t => t.id !== a.id) };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
const initialState: TableState = {
trips: [],
loading: true,
total: 0,
pages: 1,
error: null,
};
// ── Main hook ──────────────────────────────────────────────────────────────
export function useTrips() {
const [state, dispatch] = useReducer(reducer, initialState);
const [search, setSearch] = useState("");
const [status, setStatus] = useState<TripStatus | "">("");
const [page, setPage] = useState(1);
const [notification, setNotification] = useState<TripNotification | null>(null);
// Ref so the dismiss timer can be cleared if a new notification arrives
// before the previous one expires — prevents stale-closure memory leaks.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: TripNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n);
timerRef.current = setTimeout(() => setNotification(null), 4000);
}, []);
// Clear pending timer on unmount
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// ── Fetch trips ───────────────────────────────────────────────────────────
const loadTrips = useCallback(
async (p: number, q: string, st: TripStatus | "") => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const res = await tripService.getAll(
{ page: p, limit: 12, search: q || undefined, status: st || undefined },
token,
);
const payload = (
res as unknown as {
data: { data: Trip[]; pagination?: { total: number; totalPages: number } };
}
).data ?? res;
dispatch({
type: "LOAD_OK",
trips: payload.data ?? [],
total: payload.pagination?.total ?? 0,
pages: payload.pagination?.totalPages ?? 1,
});
} catch (err) {
dispatch({
type: "LOAD_ERR",
error: extractApiMessage(err, "تعذّر تحميل بيانات الرحلات. يرجى المحاولة مجدداً."),
});
}
},
[],
);
useEffect(() => {
loadTrips(page, search, status);
}, [page, search, status, loadTrips]);
// ── Create ────────────────────────────────────────────────────────────────
const createTrip = useCallback(
async (payload: CreateTripPayload): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await tripService.create(payload, token);
const newTrip = (res as unknown as { data: Trip }).data;
dispatch({ type: "ADD", trip: newTrip });
notify({ type: "success", message: "تم إضافة الرحلة بنجاح." });
return true;
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر إضافة الرحلة.") });
return false;
}
},
[notify],
);
// ── Update ────────────────────────────────────────────────────────────────
const updateTrip = useCallback(
async (id: string, payload: UpdateTripPayload): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await tripService.update(id, payload, token);
const updated = (res as unknown as { data: Trip }).data;
dispatch({ type: "UPDATE", trip: updated });
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
return true;
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
return false;
}
},
[notify],
);
// ── Delete ────────────────────────────────────────────────────────────────
const deleteTrip = useCallback(
async (id: string): Promise<boolean> => {
try {
const token = getStoredToken();
await tripService.delete(id, token);
dispatch({ type: "DELETE", id });
notify({ type: "success", message: "تم حذف الرحلة بنجاح." });
return true;
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") });
return false;
}
},
[notify],
);
// ── Helpers ───────────────────────────────────────────────────────────────
const handleSearch = useCallback((q: string) => {
setSearch(q);
setPage(1);
}, []);
const handleStatusFilter = useCallback((s: TripStatus | "") => {
setStatus(s);
setPage(1);
}, []);
return {
...state,
page,
search,
status,
setPage,
handleSearch,
handleStatusFilter,
clearError: () => dispatch({ type: "CLEAR_ERR" }),
createTrip,
updateTrip,
deleteTrip,
notification,
dismissNotification: () => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(null);
},
reload: () => loadTrips(page, search, status),
};
}

163
src/hooks/useUser.ts Normal file
View File

@@ -0,0 +1,163 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { userService } from "@/src/services/user.service";
import type { Branch } from "@/src/types/branch";
import type { Role } from "@/src/types/role";
import type {
TableAction,
TableState,
User,
UserFormData,
} from "@/src/types/user";
import type { ToastNotification } from "@/src/Components/UI"
export type Notification = ToastNotification;
// ── Reducer ──────────────────────────────────────
function tableReducer(s: TableState, a: TableAction): TableState {
switch (a.type) {
case "LOAD_START": return { ...s, loading: true, error: null };
case "LOAD_OK": return { ...s, loading: false, users: a.users, total: a.total, pages: a.pages };
case "LOAD_ERR": return { ...s, loading: false, error: a.error };
case "ADD": return { ...s, users: [a.user, ...s.users] };
case "UPDATE": return { ...s, users: s.users.map(u => u.id === a.user.id ? a.user : u) };
case "DELETE": return { ...s, users: s.users.filter(u => u.id !== a.id) };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
// ── first status ─────────────────────────────────────────────────────────
const initialState: TableState = {
users: [], loading: true, total: 0, pages: 1, error: null,
};
// ── main hook ──────────────────────────────────────────────────────────────
export function useUsers() {
const [state, dispatch] = useReducer(tableReducer, initialState);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [roles, setRoles] = useState<Role[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [notification, setNotification] = useState<ToastNotification | null>(null);
// notify with auto-dismiss after 4s
const notify = useCallback((n: ToastNotification) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
}, []);
// fetch users with pagination and search
const loadUsers = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const res = await userService.getAll(p, q, token);
const payload = res.data ?? res;
dispatch({
type: "LOAD_OK",
users: payload.data ?? [],
total: payload.meta?.total ?? 0,
pages: payload.meta?.pages ?? 1,
});
} catch {
dispatch({ type: "LOAD_ERR", error: "عذراً، حدث خطأ أثناء تحميل بيانات المستخدمين. يرجى المحاولة لاحقاً." });
}
}, []);
// fetch roles and branches for form dropdowns on mount
useEffect(() => {
const token = getStoredToken();
userService.getRoles(token)
.then(res => setRoles((res.data ?? res).data ?? []))
.catch(() => {});
userService.getBranches(token)
.then(res => setBranches((res.data ?? res).data ?? []))
.catch(() => {});
}, []);
// reload users when page or search changes
useEffect(() => {
loadUsers(page, search);
}, [page, search, loadUsers]);
// create new user
const createUser = useCallback(async (data: UserFormData): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await userService.create(data as UserFormData & { password: string }, token);
dispatch({ type: "ADD", user: res.data });
notify({ type: "success", message: "تم إنشاء مستخدم جديد بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
// update existing user
const updateUser = useCallback(async (id: string, data: UserFormData): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await userService.update(id, data, token);
dispatch({ type: "UPDATE", user: res.data });
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
// delete user
const deleteUser = useCallback(async (id: string): Promise<boolean> => {
try {
const token = getStoredToken();
await userService.delete(id, token);
dispatch({ type: "DELETE", id });
notify({ type: "success", message: "تم حذف المستخدم بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
const handleSearch = useCallback((q: string) => {
setSearch(q);
setPage(1);
}, []);
const clearError = useCallback(() => dispatch({ type: "CLEAR_ERR" }), []);
return {
//table data and status
...state,
// dropdown data for forms
roles,
branches,
// pagination and search state
page,
search,
setPage,
handleSearch,
clearError,
//main actions
createUser,
updateUser,
deleteUser,
// notifications for success/error messages
notification,
};
}

57
src/lib/api.ts Normal file
View File

@@ -0,0 +1,57 @@
const DEFAULT_BASE_URL = "/api/proxy";
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || DEFAULT_BASE_URL;
function normalizePath(path: string): string {
return path.startsWith("/") ? path : `/${path}`;
}
export async function requestJson<T>(
path: string,
init: RequestInit = {},
token: string | null = null,
): Promise<T> {
const headers = new Headers(init.headers || {});
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const endpoint = `${API_BASE_URL}${normalizePath(path)}`;
let attempt = 0;
const maxAttempts = 3;
while (attempt < maxAttempts) {
try {
const response = await fetch(endpoint, {
...init,
headers,
cache: "no-store",
method: init.method || "GET",
});
if (!response.ok) {
const json = await response.json().catch(() => null);
const message =
json?.message ||
json?.error ||
`Request failed with status ${response.status}`;
throw new Error(message);
}
return (await response.json()) as T;
} catch (error) {
attempt += 1;
if (attempt >= maxAttempts) throw error;
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 300));
}
}
throw new Error("Request failed after retries.");
}

107
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,107 @@
import { authService } from "@/src/services/auth.service";
import type { AuthUser } from "@/src/types/auth";
const AUTH_TOKEN_KEY = "auth_token";
const AUTH_USER_KEY = "auth_user";
const AUTH_TOKEN_COOKIE = "auth_token";
// ── Cookie helpers ─────────────────────────────────────────────────────────
function setTokenCookie(token: string) {
if (typeof document === "undefined") return;
const exp = new Date(Date.now() + 7 * 864e5).toUTCString();
document.cookie = `${AUTH_TOKEN_COOKIE}=${encodeURIComponent(token)}; expires=${exp}; path=/; SameSite=Lax`;
}
function deleteTokenCookie() {
if (typeof document === "undefined") return;
document.cookie = `${AUTH_TOKEN_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
}
// ── Storage ────────────────────────────────────────────────────────────────
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(AUTH_TOKEN_KEY);
}
export function getStoredUser(): AuthUser | null {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(AUTH_USER_KEY);
if (!raw) return null;
try { return JSON.parse(raw) as AuthUser; } catch { return null; }
}
export function saveAuth(token: string, user: AuthUser) {
if (typeof window === "undefined") return;
localStorage.setItem(AUTH_TOKEN_KEY, token);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
setTokenCookie(token);
}
/**
* Clear auth state: removes localStorage keys, calls /api/clear-cookie to
* remove the HttpOnly cookie (only the server can delete an HttpOnly cookie).
*/
export async function clearAuth(): Promise<void> {
if (typeof window !== "undefined") {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_USER_KEY);
deleteTokenCookie();
}
try {
await fetch("/api/clear-cookie", { method: "POST", cache: "no-store" });
} catch {
// Best-effort — if the network call fails the cookie will expire on its own
}
}
// ── Blocked roles ──────────────────────────────────────────────────────────
const BLOCKED_ROLES = ["driver", "سائق"] as const;
// ── Login ──────────────────────────────────────────────────────────────────
type RawRole = { name?: string; permissions?: Array<{ permission?: { slug?: string } }> } | string;
export async function loginUser(identity: string, password: string) {
const payload = await authService.login({ identity, password });
const { token, user } = payload.data || {};
if (!token || !user) {
throw new Error("اسم المستخدم أو كلمة المرور غير صحيحة.");
}
const rawRole = (user as unknown as { role?: RawRole }).role;
const roleName = typeof rawRole === "string"
? rawRole
: typeof rawRole === "object" && rawRole !== null
? rawRole.name
: undefined;
if (roleName && (BLOCKED_ROLES as readonly string[]).includes(roleName)) {
throw new Error("غير مصرح لك بالوصول إلى هذه اللوحة.");
}
const rolePermissions =
typeof rawRole === "object" && rawRole !== null
? (rawRole as Exclude<RawRole, string>).permissions ?? []
: [];
const permissions = Array.isArray(rolePermissions)
? rolePermissions
.map(e => e?.permission?.slug)
.filter((s): s is string => Boolean(s))
: [];
// Store HttpOnly cookie via server endpoint
try {
await fetch("/api/auth/set-cookie", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
} catch {
// Non-fatal — middleware will still work if cookie was already set
}
const fullUser: AuthUser = { ...user, role: roleName, permissions };
saveAuth(token, fullUser);
return { token, user: fullUser };
}

41
src/lib/formatters.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Format an ISO date string as a localised Arabic date.
* @example fmtDate("2026-06-08T10:00:00Z") → "٨ يونيو ٢٠٢٦"
*/
export function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
/**
* Format an ISO date string as a long Arabic date.
* @example fmtDateLong("2026-06-08T10:00:00Z") → "٨ يونيو ٢٠٢٦"
*/
export function fmtDateLong(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/**
* Format a numeric amount as Saudi Riyals.
* @example fmtAmount(1234.5) → "١٬٢٣٤٫٥٠ ر.س"
*/
export function fmtAmount(n: number): string {
return `${n.toFixed(2)} ر.س`;
}
/**
* Returns true if the date is within 90 days in the future (or already past).
*/
export function isExpiringSoon(iso?: string | null): boolean {
if (!iso) return false;
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
}

42
src/lib/order-status.ts Normal file
View File

@@ -0,0 +1,42 @@
// Single source of truth for order status display logic.
// Imports OrderStatus from src/types/order (not the legacy helperFun).
import type { OrderStatus } from "@/src/types/order";
export type { OrderStatus };
const STATUS_COLORS: Record<string, string> = {
Created: "bg-blue-50 text-blue-700 border-blue-200",
Assigned: "bg-purple-50 text-purple-700 border-purple-200",
InTransit: "bg-amber-50 text-amber-700 border-amber-200",
Delivered: "bg-emerald-50 text-emerald-700 border-emerald-200",
Returned: "bg-red-50 text-red-600 border-red-200",
Cancelled: "bg-slate-50 text-slate-600 border-slate-200",
// Legacy uppercase variants (client portal)
CREATED: "bg-blue-50 text-blue-700 border-blue-200",
IN_TRANSIT: "bg-amber-50 text-amber-700 border-amber-200",
DELIVERED: "bg-emerald-50 text-emerald-700 border-emerald-200",
CANCELLED: "bg-red-50 text-red-600 border-red-200",
};
const STATUS_LABELS: Record<string, string> = {
Created: "تم الإنشاء",
Assigned: "مُعيَّن",
InTransit: "قيد التوصيل",
Delivered: "تم التسليم",
Returned: "مُرتجع",
Cancelled: "ملغي",
CREATED: "تم الإنشاء",
IN_TRANSIT: "قيد التوصيل",
DELIVERED: "تم التسليم",
CANCELLED: "ملغي",
};
/** Tailwind class string for a status badge */
export function statusColor(status: string): string {
return STATUS_COLORS[status] ?? "bg-slate-50 text-slate-600 border-slate-200";
}
/** Arabic label for a status */
export function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status;
}

53
src/lib/session.ts Normal file
View File

@@ -0,0 +1,53 @@
export const SESSION_KEY = "lf_client";
export const SESSION_COOKIE = "lf_client_id";
export interface ClientSession {
id: string;
name: string;
email: string;
phone: string;
}
// ── Cookie helpers ─────────────────────────────────────────────────────────
export function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
export function setCookie(name: string, value: string, days = 30) {
const exp = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${exp}; path=/; SameSite=Lax`;
}
export function deleteCookie(name: string) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
}
// ── Session read / write ───────────────────────────────────────────────────
export function loadSession(): ClientSession | null {
try {
const raw = typeof localStorage !== "undefined"
? localStorage.getItem(SESSION_KEY)
: null;
if (raw) return JSON.parse(raw) as ClientSession;
} catch { /* ignore parse errors */ }
const id = getCookie(SESSION_COOKIE);
if (id) return { id, name: "", email: "", phone: "" };
return null;
}
export function saveSession(s: ClientSession) {
if (typeof localStorage !== "undefined") {
localStorage.setItem(SESSION_KEY, JSON.stringify(s));
}
setCookie(SESSION_COOKIE, s.id);
}
export function clearSession() {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(SESSION_KEY);
}
deleteCookie(SESSION_COOKIE);
}

11
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
// Minimal class-name merger used by UI components.
// Install `clsx` and `tailwind-merge` for production:
// npm i clsx tailwind-merge
// Then replace the body with:
// import { clsx } from "clsx"; import { twMerge } from "tailwind-merge";
// export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(" ");
}

View File

@@ -0,0 +1,118 @@
// middleware.ts
// Runs on the Edge runtime before any page component is rendered.
// Handles two guards for all /dashboard/* routes (and their sub-routes):
// 1. Authentication — redirects to /login if no auth cookie is present.
// 2. Role-based access control — blocks the "driver" role from admin routes.
import { NextRequest, NextResponse } from "next/server";
// The cookie name must match what the server-side login action sets.
// Previously lib/auth.ts wrote this as a JS-accessible cookie — after the
// auth.ts refactor (Issue 3) this same name is now an HttpOnly cookie.
const AUTH_COOKIE_NAME = "auth_token";
// Routes that require authentication (and block the driver role).
// The matcher below handles the routing; this constant is for documentation.
const PROTECTED_PREFIX = "/dashboard";
/**
* Lightweight JWT payload decoder.
* We only need the `role` claim — we do NOT verify the signature here
* because the backend already validates the token on every API request.
* Signature verification in middleware would require the secret to be
* bundled into the Edge runtime, which is its own security concern.
* The authoritative check is always the backend; middleware is a UX guard.
*/
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
// Base64url → Base64 → JSON
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
// atob is available in the Edge runtime
const json = atob(base64);
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// ── Guard: only apply to protected routes ─────────────────────────────────
// (The `matcher` config below already limits execution, but this is an
// explicit check for clarity and defensive depth.)
if (!pathname.startsWith(PROTECTED_PREFIX)) {
return NextResponse.next();
}
// ── Check 1: Authentication ────────────────────────────────────────────────
// Read the HttpOnly auth cookie set by the server after login.
const authCookie = request.cookies.get(AUTH_COOKIE_NAME);
if (!authCookie?.value) {
// No token → send to login, preserving the original URL so we can
// redirect back after successful login if needed.
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
// ── Check 2: Role-based access control ────────────────────────────────────
// Decode the JWT payload (not verified — see note on decodeJwtPayload above).
const payload = decodeJwtPayload(authCookie.value);
if (!payload) {
// Malformed token — treat as unauthenticated.
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
// The role field matches what lib/auth.ts stores: a plain string like
// "driver", "admin", "user" (extracted from the backend JWT during login).
const role =
typeof payload.role === "string"
? payload.role.toLowerCase()
: null;
const BLOCKED_ROLES = ["driver", "سائق"];
if (role && BLOCKED_ROLES.includes(role)) {
// Driver accounts must never access the admin dashboard.
return NextResponse.redirect(new URL("/forbidden", request.url));
}
// ── All checks passed — allow the request through ─────────────────────────
return NextResponse.next();
}
export const config = {
// Apply this middleware to the dashboard root and all sub-routes.
// Explicitly list the known sub-routes so the matcher is predictable;
// any new top-level pages added under /dashboard are automatically covered
// by the "/dashboard/:path*" pattern.
matcher: [
"/dashboard",
"/dashboard/:path*",
"/users",
"/users/:path*",
"/cars",
"/cars/:path*",
"/orders",
"/orders/:path*",
"/clients",
"/clients/:path*",
"/drivers",
"/drivers/:path*",
"/roles",
"/roles/:path*",
"/audit",
"/audit/:path*",
"/trips",
"/trips/:path*",
"/branches",
"/branches/:path*",
],
};

72
src/services/api.ts Normal file
View File

@@ -0,0 +1,72 @@
import { requestJson } from "@/src/lib/api";
function resolveBase() {
if (typeof window === "undefined") {
return process.env.INTERNAL_API_BASE_URL ?? "";
}
return "/api/proxy";
}
// ── Error type ─────────────────────────────────────────
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
// ── Core request helper ────────────────────────────────
export async function request<T>(
path: string,
init: RequestInit = {},
token?: string | null,
): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const url = `${resolveBase()}/${path.replace(/^\/+/, "")}`;
const res = await fetch(url, {
...init,
headers,
cache: "no-store",
method: init.method ?? "GET",
});
if (!res.ok) {
const json = await res.json().catch(() => null);
const message: string =
json?.message ?? json?.error ?? `HTTP ${res.status}: ${res.statusText}`;
throw new ApiError(res.status, message);
}
//return res.json() as Promise<T>;
const text = await res.text();
return (text && text.trim().length > 0 ? JSON.parse(text) : null) as T;
}
// ── Convenience shorthands ────────────────────────────
export const get = <T>(path: string, token?: string | null) =>
request<T>(path, {}, token);
export const post = <T>(path: string, body: unknown, token?: string | null) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }, token);
export const patch = <T>(path: string, body: unknown, token?: string | null) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }, token);
export const put = <T>(path: string, body: unknown, token?: string | null) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }, token);
export const del = <T>(path: string, token?: string | null) =>
request<T>(path, { method: "DELETE" }, token);

View File

@@ -0,0 +1,28 @@
import { post } from "./api";
import type { LoginResponse } from "@/src/types/auth";
export interface LoginPayload {
identity: string;
password: string;
}
export const authService = {
/**
* Authenticate a user with email / phone / username + password.
* Throws `ApiError` on network or credential failure.
*/
login: (payload: LoginPayload) =>
post<LoginResponse>("auth/login", payload),
/**
* Request a password reset email.
*/
forgotPassword: (email: string) =>
post<{ message: string }>("auth/forgot-password", { email }),
/**
* Confirm a password reset with a token.
*/
resetPassword: (token: string, newPassword: string) =>
post<{ message: string }>("auth/reset-password", { token, newPassword }),
};

129
src/services/car.service.ts Normal file
View File

@@ -0,0 +1,129 @@
import { get, post, patch, del } from "./api";
import type {
Car,
CarListResponse,
CarDetailResponse,
CarImageListResponse,
CreateCarPayload,
UpdateCarPayload,
} from "@/src/types/car";
/** 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 carService = {
/**
* GET /cars
* Fetch paginated + searchable car list.
*/
getAll: (
page = 1,
search = "",
token: string | null,
) =>
get<CarListResponse>(
`cars${buildQuery({ page, limit: 10, search: search || undefined })}`,
token,
),
/**
* GET /cars/archived
* Fetch soft-deleted cars.
*/
getArchived: (token: string | null) =>
get<CarListResponse>("cars/archived", token),
/**
* GET /cars/:id
* Fetch a single car with status history.
*/
getById: (id: string, token: string | null) =>
get<CarDetailResponse>(`cars/${id}`, token),
/**
* POST /cars
* Create a new car record.
*/
create: (payload: CreateCarPayload, token: string | null) =>
post<{ data: Car }>("cars", payload, token),
/**
* PATCH /cars/:id
* Update an existing car's details.
*/
update: (id: string, payload: UpdateCarPayload, token: string | null) =>
patch<{ data: Car }>(`cars/${id}`, payload, token),
/**
* DELETE /cars/:id
* Soft-delete a car (returns 204 No Content).
*/
delete: (id: string, token: string | null) =>
del<void>(`cars/${id}`, token),
// ── Images ──────────────────────────────────────────────────────────────
/**
* GET /car-images/car/:id
* Fetch all active images for a car.
* Supports optional query params: day, month, year, date, sortBy.
*/
getImages: (
carId: string,
token: string | null,
filters: { day?: string; month?: string; year?: string; date?: string; sortBy?: "asc" | "desc" } = {},
) =>
get<CarImageListResponse>(
`car-images/car/${carId}${buildQuery(filters as Record<string, string>)}`,
token,
),
/**
* GET /car-images/car/:id/archive
* Fetch soft-deleted images for a car.
*/
getArchivedImages: (carId: string, token: string | null) =>
get<CarImageListResponse>(`car-images/car/${carId}/archive`, token),
/**
* POST /car-images/car/:id (multipart/form-data)
* Upload one or more images.
* Uses raw fetch because multer expects FormData, not JSON.
*/
uploadImages: async (
carId: string,
files: File[],
stage: "BEFORE" | "AFTER" | "GENERAL" = "GENERAL",
token: string | null,
maintenanceId?: string,
): Promise<CarImageListResponse> => {
const form = new FormData();
files.forEach((f) => form.append("images", f));
form.append("stage", stage);
if (maintenanceId) form.append("maintenanceId", maintenanceId);
const res = await fetch(`/api/proxy/car-images/car/${carId}`, {
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<CarImageListResponse>;
},
/**
* DELETE /car-images/:imageId
* Soft-delete a single image.
*/
deleteImage: (imageId: string, token: string | null) =>
del<void>(`car-images/${imageId}`, token),
};

View File

@@ -0,0 +1,48 @@
// Mirrors user.service.ts exactly: token parameter, buildPayload, buildQuery.
import { get, post, put, del } from "./api";
import type { ApiResponse, ApiListResponse, Client, ClientFormData } from "@/src/types/client";
/** نوع الاستجابة لعملية واحدة على عميل */
export interface ClientResponse {
data: Client;
}
/** بناء query string لجلب العملاء مع البحث والصفحات */
function buildClientsQuery(page: number, search: string): string {
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
}
export const clientService = {
/** جلب قائمة العملاء مع إمكانية البحث والتصفح بين الصفحات */
getAll: (page: number, search: string, token: string | null) =>
get<ApiListResponse<Client>>(`client${buildClientsQuery(page, search)}`, token),
/** جلب عميل واحد بالـ ID (مع العناوين المرفقة) */
getById: (id: string, token: string | null) =>
get<ApiResponse<Client>>(`client/${id}`, token),
/** إنشاء عميل جديد */
create: (data: ClientFormData, token: string | null) =>
post<ClientResponse>("client", buildPayload(data), token),
/** تعديل بيانات عميل موجود */
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;
}

View File

@@ -0,0 +1,49 @@
// Mirrors user.service.ts pattern: explicit token param, buildPayload helper.
import { get, post, put, patch, del } from "./api";
import type {
ApiResponse,
ClientAddress,
ClientAddressFormData,
} from "@/src/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,
};
}

View File

@@ -0,0 +1,7 @@
import { get } from "./api";
import type { DashboardSummaryResponse } from "@/src/types/dashboard";
export const dashboardService = {
getSummary: (token: string) =>
get<DashboardSummaryResponse>("dashboard/summary", token),
};

View File

@@ -0,0 +1,273 @@
import { get, post, del, patch } from "./api";
import type {
Driver,
DriverListResponse,
DriverDetailResponse,
DriverReportResponse,
CreateDriverPayload,
UpdateDriverPayload,
} from "@/src/types/driver";
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("&");
}
// ── Image URL normaliser ──────────────────────────────────────────────────────
// Returns the URL as-is — <img> tags are not subject to CORS, so we can point
// directly at the backend without a Next.js proxy hop.
// Only strips query-string noise or fixes obviously malformed paths if needed.
function normaliseImageUrl(url: string | null | undefined): string | null {
if (!url) return null;
return url;
}
function mapDriverImages<
T extends {
photoUrl?: string | null;
nationalPhotoUrl?: string | null;
driverCardPhotoUrl?: string | null;
},
>(driver: T): T {
return {
...driver,
photoUrl: normaliseImageUrl(driver.photoUrl),
nationalPhotoUrl: normaliseImageUrl(driver.nationalPhotoUrl),
driverCardPhotoUrl: normaliseImageUrl(driver.driverCardPhotoUrl),
};
}
// ── Image compression ─────────────────────────────────────────────────────────
// Resizes to max 1024px while preserving the original file type and name.
async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<File> {
return new Promise((resolve) => {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
let { width, height } = img;
// Guard: malformed/zero-dimension image — bail out to original file
if (!width || !height) {
resolve(file);
return;
}
// Only resize if actually oversized
if (width <= maxPx && height <= maxPx) {
resolve(file);
return;
}
if (width >= height) {
height = Math.round((height / width) * maxPx);
width = maxPx;
} else {
width = Math.round((width / height) * maxPx);
height = maxPx;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) { resolve(file); return; }
// PNG/GIF have transparency — paint white behind them first so toBlob
// doesn't hand back a canvas with undefined/black fill in some browsers.
// (Skip this for formats that don't carry alpha, like plain JPEG.)
const mimeType = file.type || "image/jpeg";
if (mimeType !== "image/jpeg") {
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(img, 0, 0, width, height);
// quality param is meaningless (and occasionally mishandled) for
// lossless formats — only pass it for jpeg/webp.
const isLossy = mimeType === "image/jpeg" || mimeType === "image/webp";
canvas.toBlob(
(blob) => {
// Guard: empty/undersized blob means the encode failed — fall
// back to the original file rather than uploading garbage.
if (!blob || blob.size < 100) {
resolve(file);
return;
}
resolve(new File([blob], file.name, { type: mimeType, lastModified: Date.now() }));
},
mimeType,
isLossy ? quality : undefined,
);
};
img.onerror = () => { URL.revokeObjectURL(objectUrl); resolve(file); };
img.src = objectUrl;
});
}
async function compressPayloadImages<
T extends { photo?: File; nationalPhoto?: File; driverCardPhoto?: File },
>(payload: T): Promise<T> {
const result = { ...payload };
if (result.photo) result.photo = await compressImage(result.photo);
if (result.nationalPhoto) result.nationalPhoto = await compressImage(result.nationalPhoto);
if (result.driverCardPhoto) result.driverCardPhoto = await compressImage(result.driverCardPhoto);
return result;
}
// ── Service ───────────────────────────────────────────────────────────────────
export const driverService = {
/** GET /driver — paginated + searchable list */
getAll: async (page = 1, search = "", token: string | null): Promise<DriverListResponse> => {
const res = await get<DriverListResponse>(
`driver${buildQuery({ page, limit: 12, search: search || undefined })}`,
token,
);
const body = (res as unknown as { data: { data: Driver[]; pagination: unknown; meta?: unknown } }).data;
body.data = body.data.map(mapDriverImages);
return res;
},
/** GET /driver/archived */
getArchived: async (token: string | null): Promise<DriverListResponse> => {
const res = await get<DriverListResponse>("driver/archived", token);
const body = (res as unknown as { data: { data: Driver[] } }).data;
body.data = body.data.map(mapDriverImages);
return res;
},
/** GET /driver/me */
getMe: async (token: string | null): Promise<DriverDetailResponse> => {
const res = await get<DriverDetailResponse>("driver/me", token);
const body = res as unknown as { data: Driver };
body.data = mapDriverImages(body.data);
return res;
},
/** GET /driver/:id */
getById: async (id: string, token: string | null): Promise<DriverDetailResponse> => {
const res = await get<DriverDetailResponse>(`driver/${id}`, token);
const body = res as unknown as { data: Driver };
body.data = mapDriverImages(body.data);
return res;
},
/** POST /driver (JSON — no files) */
create: (payload: CreateDriverPayload, token: string | null) =>
post<{ data: Driver }>("driver", payload, token),
/** PATCH /driver/:id (JSON — no files) */
update: async (id: string, payload: UpdateDriverPayload, token: string | null): Promise<{ data: Driver }> => {
const res = await patch<{ data: Driver }>(`driver/${id}`, payload, token);
const body = res as unknown as { data: Driver };
body.data = mapDriverImages(body.data);
return res;
},
/** DELETE /driver/:id */
delete: (id: string, token: string | null) =>
del<void>(`driver/${id}`, token),
/** GET /drivers/:id/reports/daily?date=YYYY-MM-DD */
getDailyReport: (id: string, date: string, token: string | null) =>
get<DriverReportResponse>(
`drivers/${id}/reports/daily?date=${encodeURIComponent(date)}`,
token,
),
// ── Multipart helpers (with auto compression) ─────────────────────────────
/**
* POST /driver (multipart/form-data)
* Compresses images before upload to stay under backend LIMIT_FILE_SIZE.
*/
createWithImages: async (
payload: CreateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
token: string | null,
): Promise<{ data: Driver }> => {
const compressed = await compressPayloadImages(payload);
const form = new FormData();
Object.entries(compressed).forEach(([key, val]) => {
if (val !== undefined && val !== null && !(val instanceof File)) {
form.append(key, String(val));
}
});
if (compressed.photo) form.append("photo", compressed.photo);
if (compressed.nationalPhoto) form.append("nationalPhoto", compressed.nationalPhoto);
if (compressed.driverCardPhoto) form.append("driverCardPhoto", compressed.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}`);
}
const data = await res.json() as { data: Driver };
data.data = mapDriverImages(data.data);
return data;
},
/**
* PATCH /driver/:id (multipart/form-data)
* Compresses images before upload to stay under backend LIMIT_FILE_SIZE.
*/
updateWithImages: async (
id: string,
payload: UpdateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
token: string | null,
): Promise<{ data: Driver }> => {
const compressed = await compressPayloadImages(payload);
const form = new FormData();
Object.entries(compressed).forEach(([key, val]) => {
if (val !== undefined && val !== null && !(val instanceof File)) {
form.append(key, String(val));
}
});
if (compressed.photo) form.append("photo", compressed.photo);
if (compressed.nationalPhoto) form.append("nationalPhoto", compressed.nationalPhoto);
if (compressed.driverCardPhoto) form.append("driverCardPhoto", compressed.driverCardPhoto);
const res = await fetch(`/api/proxy/driver/${id}`, {
method: "PATCH",
// Do NOT set Content-Type — browser sets it with the correct boundary
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}`);
}
const data = await res.json() as { data: Driver };
data.data = mapDriverImages(data.data);
return data;
},
};

11
src/services/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export { request, get, post, put, patch, del, ApiError } from "./api";
export { authService } from "./auth.service";
export { dashboardService } from "./dashboard.service";
export { clientService } from "./client.service";
export { clientAddressService } from "./clientAddress.service";
export { userService } from "./user.service";
export { carService } from "./car.service";
export { driverService } from "./driver.service";
export { tripService } from "./trip.service";
export { orderService } from "./order.service";
export { roleService } from "./role.service";

View File

@@ -0,0 +1,45 @@
// services/order.service.ts
import { get, post, put, del } from "./api";
import type {
Order,
CreateOrderPayload,
UpdateOrderPayload,
UpdateOrderStatusPayload,
TransferOrderPayload,
OrdersResponse,
} from "@/src/types/order";
export const orderService = {
/** Get all orders (paginated) */
getAll: (params?: Record<string, string | number>) => {
const qs = params
? "?" + new URLSearchParams(params as Record<string, string>).toString()
: "";
return get<OrdersResponse>(`orders${qs}`);
},
/** Get order by ID */
getById: (id: string) => get<Order>(`orders/${id}`),
/** Create new order */
create: (payload: CreateOrderPayload) =>
post<Order>("orders", payload),
/** Update order metadata */
update: (id: string, payload: UpdateOrderPayload) =>
put<Order>(`orders/${id}`, payload),
/** Update order status with optional reason */
updateStatus: (id: string, payload: UpdateOrderStatusPayload) =>
put<Order>(`orders/${id}/status`, payload),
/** Transfer order to a different trip */
transfer: (id: string, payload: TransferOrderPayload) =>
put<Order>(`orders/${id}/transfer`, payload),
/** Soft-delete an order */
delete: (id: string) => del<void>(`orders/${id}`),
/** Get archived orders */
getArchived: () => get<OrdersResponse>("orders/archived"),
};

View File

@@ -0,0 +1,95 @@
import { requestJson } from "@/src/lib/api";
import { get, post, put, del, patch } from "./api";
// ── Types ─────────────────────────────────────────────────────────────────────
export interface Permission {
id: string;
name: string;
slug: string;
module: string;
}
export interface Role {
id: string;
name: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
permissions?: Array<{
permission: Permission;
}>;
}
export interface RoleFormData {
name: string;
description: string;
permissionIds: string[];
}
export interface ApiPaginatedResponse<T> {
data: {
data: T[];
pagination?: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
export interface RoleResponse {
data: Role;
}
export interface PermissionsResponse {
data: {
premissions: Permission[];
};
}
/** Build query string for paginated role listing */
function buildRolesQuery(page: number, search: string): string {
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
}
export const roleService = {
/** Fetch paginated roles list */
getAll: (page: number, search: string, token: string | null) =>
get<ApiPaginatedResponse<Role>>(`role${buildRolesQuery(page, search)}`, token),
/** Fetch a single role by ID (includes permissions) */
getById: (id: string, token: string | null) =>
get<{ data: Role }>(`role/${id}`, token),
/** Create a new role */
create: (data: RoleFormData, token: string | null) =>
post<RoleResponse>("role", { name: data.name, description: data.description }, token),
/** Update role name/description */
update: (id: string, data: Partial<RoleFormData>, token: string | null) =>
put<RoleResponse>(`role/${id}`, { name: data.name, description: data.description }, token),
/** Soft-delete a role */
delete: (id: string, token: string | null) =>
del<void>(`role/${id}`, token),
/** Fetch all available permissions for checkbox list */
getPermissions: (token: string | null) =>
get<PermissionsResponse>("premission?limit=200", token),
/** Assign a single permission to a role */
assignPermission: (roleId: string, permissionId: string, token: string | null) =>
post<unknown>(`role/${roleId}/permissions`, { permissionId }, token),
/** Remove a permission from a role */
removePermission: (roleId: string, permissionId: string, token: string | null) =>
del<unknown>(`role/${roleId}/permissions/${permissionId}`, token),
/** Atomically replace all permissions assigned to a role */
setPermissions: (roleId: string, permissionIds: string[], token: string | null) =>
patch<{ data: Role }>(`role/${roleId}/permissions`, {
method: "PATCH",
body: JSON.stringify({ permissionIds }),
}, token),
};

View File

@@ -0,0 +1,109 @@
import { get, post, patch, del } from "./api";
import type {
Trip,
TripListResponse,
TripDetailResponse,
TripReportResponse,
CreateTripPayload,
UpdateTripPayload,
TripListParams,
} from "@/src/types/trip";
/** 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 tripService = {
/**
* GET /trip
* Fetch paginated + filterable + searchable trip list.
* Requires: "read-trip" permission
*/
getAll: (params: TripListParams = {}, token: string | null) =>
get<TripListResponse>(`trip${buildQuery({ ...params })}`, token),
/**
* GET /trip/archived
* Fetch soft-deleted (archived) trips. Same filters as getAll.
* Requires: "read-deleted-trip" permission
*/
getArchived: (params: TripListParams = {}, token: string | null) =>
get<TripListResponse>(`trip/archived${buildQuery({ ...params })}`, token),
/**
* GET /trip/archived/:id
* Fetch a single archived trip with full details.
* Requires: "read-deleted-trip" permission
*/
getArchivedById: (id: string, token: string | null) =>
get<TripDetailResponse>(`trip/archived/${id}`, token),
/**
* GET /trip/:id
* Fetch a single trip with full driver/car details.
* Requires: "read-trip" permission
*/
getById: (id: string, token: string | null) =>
get<TripDetailResponse>(`trip/${id}`, token),
/**
* POST /trip
* Create a new trip.
* Side effect: driver & car flip to status "InTrip" automatically.
* Requires: "create-trip" permission
*/
create: (payload: CreateTripPayload, token: string | null) =>
post<{ data: Trip }>("trip", payload, token),
/**
* PATCH /trip/:id
* Update trip data / status / reassign driver or car.
* Side effects:
* - changing driverId: old driver -> Active, new driver -> InTrip
* - changing carId: same logic for cars
* - status Completed/Cancelled: driver & car both -> Active
* Requires: "update-trip" permission
*/
update: (id: string, payload: UpdateTripPayload, token: string | null) =>
patch<{ data: Trip }>(`trip/${id}`, payload, token),
/**
* DELETE /trip/:id
* Soft-delete a trip (isDeleted=true, deletedAt=now()). 204 No Content.
* Requires: "delete-trip" permission
*/
delete: (id: string, token: string | null) =>
del<void>(`trip/${id}`, token),
// ── Reports ───────────────────────────────────────────────────────────────
/**
* GET /trip/:id/reports
* Generate the Trip Manifest report (HTML, saved server-side).
* Returns { reportUrl }
* Requires: "generate-trip-report" permission
*/
getReport: (id: string, token: string | null) =>
get<TripReportResponse>(`trip/${id}/reports`, token),
/**
* GET /trip/:id/reports/client/:clientId
* Generate a client-filtered version of the trip report.
* Returns { reportUrl }
* Requires: "generate-trip-report" permission
*/
getClientReport: (id: string, clientId: string, token: string | null) =>
get<TripReportResponse>(`trip/${id}/reports/client/${clientId}`, token),
};

View File

@@ -0,0 +1,71 @@
// خدمة المستخدمين — كل نداءات الـ API الخاصة بالمستخدمين تكون هنا فقط
import { get, post, put, del } from "./api";
import type { ApiListResponse, User, UserFormData } from "@/src/types/user";
import type { Role } from "@/src/types/role";
import type { Branch } from "@/src/types/branch";
/** نوع الاستجابة لعملية واحدة على مستخدم */
export interface UserResponse {
data: User;
}
/** بناء query string لجلب المستخدمين مع البحث والصفحات */
function buildUsersQuery(page: number, search: string): string {
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
}
export const userService = {
/** جلب قائمة المستخدمين مع إمكانية البحث والتصفح بين الصفحات */
getAll: (page: number, search: string, token: string | null) =>
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
/** إنشاء مستخدم جديد */
create: (
data: Omit<UserFormData, "password"> & { password: string },
token: string | null,
) => post<UserResponse>("users", buildPayload(data, true), token),
/** تعديل بيانات مستخدم موجود */
update: (id: string, data: UserFormData, token: string | null) =>
put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
/** حذف مستخدم */
delete: (id: string, token: string | null) => del<void>(`users/${id}`, token),
//get role
getRoles: (token: string | null) =>
get<{ data: { data: Role[] } }>("role?limit=100", token),
//get Branches for user form
getBranches: (token: string | null) =>
get<{ data: { data: Branch[] } }>("branches?limit=100", token),
//get user by id
getById: (id: string, token: string | null) =>
get<{
data: User & {
photo: string | null;
refreshToken: string | null;
isDeleted: boolean;
deletedAt: string | null;
updatedAt: string;
passwordChangedAt: string | null;
role: { name: string; description: string };
branch: { name: string };
};
}>(`users/${id}`, token),
};
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
function buildPayload(
data: UserFormData,
isNew: boolean,
): Record<string, string> {
const payload: Record<string, string> = {
name: data.name.trim(),
phone: data.phone.trim(),
roleId: data.roleId,
branchId: data.branchId,
};
if (data.email) payload.email = data.email.trim();
if (isNew && data.password) payload.password = data.password;
else if (!isNew && data.password) payload.password = data.password;
return payload;
}

140
src/styles/tokens.css Normal file
View File

@@ -0,0 +1,140 @@
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
/* ── Brand blue — مأخوذ من login مباشرة ── */
--color-brand-50: #EFF6FF;
--color-brand-100: #DBEAFE;
--color-brand-200: #BFDBFE;
--color-brand-300: #93C5FD;
--color-brand-400: #60A5FA;
--color-brand-500: #3B82F6;
--color-brand-600: #2563EB;
--color-brand-700: #1D4ED8;
--color-brand-800: #1E3A8A;
--color-brand-900: #172554;
/* ── Sidebar gradient stops ──
NOTE: kept as named stops only (top/mid/bottom), never left/right,
so the gradient direction is safe to reuse unchanged in RTL. The
gradient in Sidebar.tsx already runs top→bottom (175deg), which is
direction-agnostic — no change needed for RTL. */
--sidebar-grad-top: #1E3A8A;
--sidebar-grad-mid: #1D4ED8;
--sidebar-grad-bottom: #1565C0;
/* ── Slate — نفس login ── */
--color-slate-50: #F8FAFC;
--color-slate-100: #F1F5F9;
--color-slate-200: #E2E8F0;
--color-slate-300: #CBD5E1;
--color-slate-400: #94A3B8;
--color-slate-500: #64748B;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1E293B;
--color-slate-900: #0F172A;
/* ── Status colors ── */
--color-success: #16A34A;
--color-success-light: #DCFCE7;
--color-success-border:#BBF7D0;
--color-warning: #D97706;
--color-warning-light: #FFFBEB;
--color-warning-border:#FDE68A;
--color-danger: #DC2626;
--color-danger-light: #FEF2F2;
--color-danger-border: #FECACA;
--color-info: #2563EB;
--color-info-light: #EFF6FF;
--color-info-border: #BFDBFE;
/* ── Surfaces — light كامل زي login ── */
--color-surface: #FFFFFF;
--color-surface-muted: var(--color-slate-50);
--color-surface-sunken: var(--color-slate-100);
--color-border: var(--color-slate-200);
--color-border-strong: var(--color-slate-300);
/* ── Text ── */
--color-text-primary: var(--color-slate-900);
--color-text-secondary: var(--color-slate-600);
--color-text-muted: var(--color-slate-500);
--color-text-hint: var(--color-slate-400);
--color-text-inverse: #FFFFFF;
/* ── للـ dashboard pages — light بدل dark ── */
--color-surface-dark: var(--color-slate-50);
--color-surface-dark-raised: var(--color-surface);
--color-surface-dark-card: var(--color-surface);
--color-border-dark: var(--color-slate-200);
--color-text-dark-primary: var(--color-slate-900);
--color-text-dark-muted: var(--color-slate-500);
/* ── Typography ── */
--font-sans: 'Plus Jakarta Sans', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
/* ── Radius ── */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-full: 9999px;
/* ── Layout ── */
--sidebar-width: 240px;
--sidebar-width-compact: 72px; /* NEW: collapsed icon rail for md/sm screens */
--topbar-height: 56px;
--page-padding-x: 24px;
/* ── Shadows ──
NOTE: shadows here are blur+spread only (no x-offset), so they are
already direction-neutral and need no RTL variant. If a future
shadow ever needs an x-offset, derive it from `--start`/`--end`
rather than hardcoding a flipped value inline. */
--shadow-card: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow-overlay: 0 10px 40px rgba(0,0,0,.1);
/* ── Transitions ── */
--transition-base: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
.animate-spin { animation: none; }
}
/* ──────────────────────────────────────────────────────
RTL / LTR direction layer
Toggled by `dir="rtl"` / `dir="ltr"` on <html> (set in the
root app/layout.tsx). Everything below uses CSS logical
properties so it self-flips with the `dir` attribute —
no JS, no duplicate stylesheets, no per-component branching.
────────────────────────────────────────────────────── */
[dir="rtl"] {
--start: right;
--end: left;
}
[dir="ltr"],
:root {
--start: left;
--end: right;
}
/* Utility for any inline-direction border that previously assumed LTR
(e.g. a left border used as "this edge touches the rail"). */
.border-inline-start {
border-inline-start: 1px solid var(--color-border-dark);
border-inline-end: none;
}
/* Numerals, codes, and API paths read LTR even inside an RTL document
(see Edge Cases in the report). Apply to any inline span wrapping
such content. */
.ltr-embed {
direction: ltr;
unicode-bidi: isolate;
display: inline-block;
}

16
src/types/auth.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface AuthUser {
id?: string;
name?: string;
userName?: string;
email?: string;
phone?: string;
role?: string;
permissions?: string[];
roleId?: string;
[key: string]: unknown;
}
export interface LoginResponse {
token: string;
user: AuthUser;
}

4
src/types/branch.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Branch {
id: string;
name: string;
}

225
src/types/car.ts Normal file
View File

@@ -0,0 +1,225 @@
// All TypeScript interfaces for the Car module.
export type CarStatus = "Active" | "InMaintenance" | "InTrip" | "Inactive";
export type InsuranceStatus = "Valid" | "Expired" | "NotInsured";
export type ImpoundStatus = "None" | string;
export type ImageStage = "BEFORE" | "AFTER" | "GENERAL";
// ── Status display config ────────────────────────────────────────────────────
// Used by page.tsx (CarCard) and CarDetailPanel
export const STATUS_MAP: Record<
CarStatus,
{ label: string; color: string; bg: string; border: string; dot: string }
> = {
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
InMaintenance: { label: "صيانة", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
InTrip: { label: "في رحلة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
Inactive: { label: "غير نشط", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0", dot: "#94A3B8" },
};
export const INS_MAP: Record<InsuranceStatus, { label: string; color: string }> = {
Valid: { label: "سارٍ", color: "#16A34A" },
Expired: { label: "منتهي", color: "#DC2626" },
NotInsured: { label: "غير مؤمَّن", color: "#D97706" },
};
// ── Stage display config ─────────────────────────────────────────────────────
// Used by CarImageGallery
export const STAGE_MAP: Record<ImageStage, { label: string; color: string; bg: string }> = {
BEFORE: { label: "قبل", color: "#854D0E", bg: "#FFFBEB" },
AFTER: { label: "بعد", color: "#166534", bg: "#DCFCE7" },
GENERAL: { label: "عام", color: "#1E40AF", bg: "#EFF6FF" },
};
// ── Shared date helpers ───────────────────────────────────────────────────────
export function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric", month: "long", day: "numeric",
});
}
export function fmtDateShort(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
});
}
/** Returns true if the date is within 90 days in the future (or already past) */
export function isExpiringSoon(iso?: string | null): boolean {
if (!iso) return false;
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
}
// ── Core Car ─────────────────────────────────────────────────────────────────
export interface Car {
id: string;
manufacturer: string;
model: string;
year: number;
color?: string | null;
plateNumber: string;
plateLetters: string;
plateType?: string | null;
registrationNumber?: string | null;
vinNumber?: string | null;
ownerNationalId?: string | null;
branchId?: string | null;
branch?: { id?: string; name: string } | null;
actualUserId?: string | null;
actualUserStartDate?: string | null;
ownershipStartDate?: string | null;
// Regulatory
registrationIssueDate?: string | null;
registrationExpiryDate?: string | null;
registrationPhoto?: string | null;
insuranceStatus?: InsuranceStatus | null;
insuranceExpiryDate?: string | null;
insurancePhoto?: string | null;
inspectionStatus?: string | null;
inspectionExpiryDate?: string | null;
operationCardNumber?: string | null;
operationCardExpiry?: string | null;
gpsDeviceId?: string | null;
currentStatus: CarStatus;
currentImpoundStatus?: string | null;
capacity?: number | null;
weight?: number | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
// Expanded relations (detail view)
statusHistory?: CarStatusHistoryEntry[];
}
export interface CarStatusHistoryEntry {
id: string;
carStatus: CarStatus;
impoundStatus?: string | null;
createdAt: string;
}
// ── Car Image ─────────────────────────────────────────────────────────────────
export interface CarImage {
id: string;
carId: string;
maintenanceId?: string | null;
image: string;
publicId?: string | null;
stage?: ImageStage | null;
url?: string;
createdAt: string;
updatedAt: string;
isActive: boolean;
isDeleted: boolean;
}
// ── Payloads ──────────────────────────────────────────────────────────────────
export interface CreateCarPayload {
manufacturer: string;
model: string;
year: number;
color?: string;
plateNumber: string;
plateLetters: string;
plateType?: string;
registrationNumber?: string;
vinNumber?: string;
ownerNationalId?: string;
branchId?: string;
actualUserId?: string;
actualUserStartDate?: string;
ownershipStartDate?: string;
registrationIssueDate?: string;
registrationExpiryDate?: string;
registrationPhoto?: string;
insuranceStatus?: InsuranceStatus;
insuranceExpiryDate?: string;
insurancePhoto?: string;
inspectionStatus?: string;
inspectionExpiryDate?: string;
operationCardNumber?: string;
operationCardExpiry?: string;
gpsDeviceId?: string;
currentStatus?: CarStatus;
currentImpoundStatus?: string;
capacity?: number;
weight?: number;
}
export type UpdateCarPayload = Partial<CreateCarPayload> & { isActive?: boolean };
// ── API Responses ─────────────────────────────────────────────────────────────
export interface CarListResponse {
data: {
data: Car[];
pagination: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
export interface CarDetailResponse {
data: Car;
}
export interface CarImageListResponse {
data: CarImage[];
}
// ── Form state ────────────────────────────────────────────────────────────────
export interface CarFormErrors {
manufacturer?: string;
model?: string;
year?: string;
plateNumber?: string;
plateLetters?: string;
registrationNumber?: string;
vinNumber?: string;
}
// ── Toast ─────────────────────────────────────────────────────────────────────
export interface ToastMsg {
type: "success" | "error";
message: string;
}
// — CarFormErrors ──────────────────────────────────
export interface CarFormErrors {
manufacturer?: string;
model?: string;
year?: string;
color?: string;
plateNumber?: string;
plateLetters?: string;
plateType?: string;
registrationNumber?: string;
vinNumber?: string;
branchId?: string;
currentStatus?: string;
insuranceStatus?: string;
registrationExpiryDate?: string;
insuranceExpiryDate?: string;
inspectionExpiryDate?: string;
gpsDeviceId?: string;
capacity?: string;
weight?: string;
}

129
src/types/client.ts Normal file
View File

@@ -0,0 +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;
clientId: string;
label: string; // e.g. "Billing", "Shipping", "HQ"
street: 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 interface ApiError {
message: string;
errors?: Record<string, string[]>;
}

41
src/types/dashboard.ts Normal file
View File

@@ -0,0 +1,41 @@
export interface DashboardStats {
clients: number;
orders: number;
trips: number;
cars: number;
drivers: number;
}
export interface AlertItem {
message: string;
[key: string]: unknown;
}
export interface ActiveTrip {
id: string;
tripNumber: string;
title: string;
progress: number;
}
export interface AccountSecurity {
lastLogin: string | null;
requestMeta: Record<string, unknown> | null;
}
export interface DashboardSummary {
stats: DashboardStats;
alerts: {
expiringCars: AlertItem[];
expiringDrivers: AlertItem[];
upcomingMaint: AlertItem[];
};
activeTrips: ActiveTrip[];
accountSecurity: AccountSecurity;
}
export interface DashboardSummaryResponse {
success: boolean;
message: string;
data: DashboardSummary;
}

127
src/types/driver.ts Normal file
View File

@@ -0,0 +1,127 @@
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;
photoUrl?: string | null;
nationalPhotoUrl?: string | null;
driverCardPhotoUrl?: string | null;
}
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: "مقيدة",
};

97
src/types/order.ts Normal file
View File

@@ -0,0 +1,97 @@
export type OrderStatus =
| "Created"
| "Assigned"
| "InTransit"
| "Delivered"
| "Returned"
| "Cancelled";
export type PaymentMethod = "Cash" | "Card" | "Prepaid";
export type PaymentStatus = "Pending" | "Paid" | "Failed" | "Refunded";
export interface OrderAddress {
details?: {
city?: string;
district?: string;
street?: string;
buildingNo?: string;
unitNo?: string;
zipCode?: string;
};
location?: {
coordinates?: [number, number];
};
}
export interface Order {
id: string;
shipmentNumber: string;
recipientName: string;
recipientPhone: string;
currentStatus: OrderStatus;
clientId: string;
tripId?: string | null;
totalPrice?: number | null;
subTotal?: number | null;
vatAmount?: number | null;
vatRate?: number | null;
paymentMethod?: PaymentMethod | null;
paymentStatus?: PaymentStatus | null;
type?: string | null;
quantity: number;
weight?: number | null;
deliveryAddressId?: string | null;
pickupAddressId?: string | null;
createdAt: string;
updatedAt: string;
statusHistory?: Array<{
id: string;
status: OrderStatus;
reason?: string | null;
createdAt: string;
}>;
client?: { id: string; name: string; phone: string };
trip?: { id: string; tripNumber: string } | null;
}
export interface CreateOrderPayload {
shipmentNumber: string;
recipientName: string;
recipientPhone: string;
clientId: string;
tripId?: string;
subTotal?: number;
vatRate?: number;
vatAmount?: number;
totalPrice?: number;
paymentMethod?: PaymentMethod;
paymentStatus?: PaymentStatus;
type?: string;
quantity?: number;
weight?: number;
deliveryAddress?: OrderAddress;
pickupAddressId?: string;
}
export interface UpdateOrderPayload extends Partial<CreateOrderPayload> {
currentStatus?: OrderStatus;
}
export interface UpdateOrderStatusPayload {
status: OrderStatus;
reason?: string;
}
export interface TransferOrderPayload {
tripId: string;
}
export interface OrdersResponse {
data: Order[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}

4
src/types/role.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Role {
id: string;
name: string;
}

156
src/types/trip.ts Normal file
View File

@@ -0,0 +1,156 @@
export type TripStatus = "Scheduled" | "InProgress" | "Completed" | "Cancelled";
// ── Embedded summaries returned inside Trip responses ───────────────────────
// NOTE: Car has no dedicated types/car.ts in this handoff — these summaries
// are inferred from the response field list in trip_api_reference.html.
// If a real types/car.ts already exists in the project, prefer importing
// from there instead of duplicating this shape.
export interface TripDriverSummary {
id?: string;
name: string;
userName?: string | null;
email?: string | null;
address?: string | null;
nationality?: string | null;
nationalId?: string | null;
gosiNumber?: string | null;
phone: string;
licenseNumber?: string | null;
driverCardNumber?: string | null;
}
export interface TripCarSummary {
id?: string;
manufacturer: string;
model: string;
year?: number | null;
color?: string | null;
plateNumber: string;
plateLetters?: string | null;
plateType?: string | null;
registrationNumber?: string | null;
ownerNationalId?: string | null;
}
export interface TripBranchSummary {
id?: string;
name: string;
}
// ── Core Trip ─────────────────────────────────────────────────────────────
export interface Trip {
id: string;
tripNumber: string;
title: string;
startTime?: string | null;
endTime?: string | null;
status: TripStatus;
collectedCount: number;
deliveredCount: number;
returnedCount: number;
totalCashCollected?: number | string | null;
notes?: string | null;
endReason?: string | null;
isDeleted?: boolean;
deletedAt?: string | null;
driverId?: string;
carId?: string;
branchId?: string | null;
driver?: TripDriverSummary | null;
car?: TripCarSummary | null;
branch?: TripBranchSummary | null;
createdAt: string;
updatedAt: string;
}
// ── Payloads ─────────────────────────────────────────────────────────────────
export interface CreateTripPayload {
title: string;
driverId: string;
carId: string;
startTime?: string;
endTime?: string;
branchId?: string;
status?: TripStatus;
collectedCount?: number;
deliveredCount?: number;
returnedCount?: number;
totalCashCollected?: number | string;
notes?: string;
endReason?: string;
}
export type UpdateTripPayload = Partial<CreateTripPayload> & {
/** Reason for changing driverId/carId — recorded in history. Update-only field. */
reason?: string;
};
// ── List query params (GET /trips) ───────────────────────────────────────────
export interface TripListParams {
status?: TripStatus;
driverId?: string;
carId?: string;
branchId?: string;
/** Searches across tripNumber, status, title, carId, driverId */
search?: string;
sort?: "createdAt" | "tripNumber" | "status" | "startTime" | "endTime";
page?: number;
limit?: number;
}
// ── API Responses ─────────────────────────────────────────────────────────────
export interface TripListResponse {
data: {
data: Trip[];
pagination: { total: number; page: number; limit: number; totalPages: number };
};
}
export interface TripDetailResponse {
data: Trip;
}
export interface TripReportResponse {
data: {
reportUrl: string;
};
}
// ── Form state ────────────────────────────────────────────────────────────────
export interface TripFormErrors {
title?: string;
driverId?: string;
carId?: string;
startTime?: string;
endTime?: string;
branchId?: string;
status?: string;
collectedCount?: string;
deliveredCount?: string;
returnedCount?: string;
totalCashCollected?: string;
notes?: string;
endReason?: string;
reason?: string;
}
// ── Status display config (mirrors DRIVER_STATUS_MAP) ───────────────────────
export const TRIP_STATUS_MAP: Record<
TripStatus,
{ label: string; color: string; bg: string; border: string; dot: string }
> = {
Scheduled: { label: "مجدولة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
InProgress: { label: "جارية", color: "#92400E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
Completed: { label: "مكتملة", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
Cancelled: { label: "ملغاة", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" },
};

66
src/types/user.ts Normal file
View File

@@ -0,0 +1,66 @@
export interface User {
id: string;
name: string;
userName?: string;
email?: string;
phone: string;
isActive: boolean;
createdAt: string;
role?: { id?: string; name: string };
branch?: { id?: string; name: string };
}
export interface UserFormData {
name: string;
email: string;
phone: string;
password: string;
roleId: string;
branchId: string;
}
export interface FormErrors {
name?: string;
email?: string;
phone?: string;
password?: string;
roleId?: string;
branchId?: string;
}
export interface ApiListResponse<T> {
data: {
data: T[];
pagination: { total: number; page: number; pages: number ; };
meta?: { total: number; pages: number };
};
}
export type TableState = {
users: User[];
loading: boolean;
total: number;
pages: number;
error: string | null;
};
export type TableAction =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; users: User[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; user: User }
| { type: "UPDATE"; user: User }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };
export interface UserDetail extends User {
photo: string | null;
refreshToken: string | null;
isDeleted: boolean;
deletedAt: string | null;
updatedAt: string;
passwordChangedAt: string | null;
role: { id?: string; name: string; description?: string };
branch: { id?: string; name: string };
}

51
src/utils/helperFun.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* @deprecated Use imports from src/lib/ instead:
* - Session helpers → src/lib/session.ts
* - Formatters → src/lib/formatters.ts
* - Auth helpers → src/lib/auth.ts
*
* This file is kept only for backwards compatibility during migration.
* It will be removed in Phase 3.
*/
// Re-export from canonical locations so existing imports still resolve.
export {
SESSION_KEY,
SESSION_COOKIE,
getCookie,
setCookie,
deleteCookie,
loadSession,
saveSession,
clearSession,
} from "@/src/lib/session";
export type { ClientSession } from "@/src/lib/session";
export { fmtDate, fmtAmount } from "@/src/lib/formatters";
// ── Order Status (legacy — now in src/lib/order-status.ts) ────────────────
/** @deprecated import from src/lib/order-status.ts */
export type OrderStatus = "CREATED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
/** @deprecated import from src/lib/order-status.ts */
export function statusColor(status: OrderStatus): string {
const map: Record<OrderStatus, string> = {
CREATED: "bg-blue-50 text-blue-700 border-blue-200",
IN_TRANSIT: "bg-amber-50 text-amber-700 border-amber-200",
DELIVERED: "bg-emerald-50 text-emerald-700 border-emerald-200",
CANCELLED: "bg-red-50 text-red-600 border-red-200",
};
return map[status];
}
/** @deprecated import from src/lib/order-status.ts */
export function statusLabel(status: OrderStatus): string {
const map: Record<OrderStatus, string> = {
CREATED: "تم الإنشاء",
IN_TRANSIT: "قيد التوصيل",
DELIVERED: "تم التسليم",
CANCELLED: "ملغي",
};
return map[status];
}

33
src/utils/logo.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { FC } from "react";
interface LogoProps {
white?: boolean;
}
const Logo: FC<LogoProps> = ({ white = false }) => (
<div className="flex items-center gap-2">
<div style={{
width: 32, height: 32,
background: white ? "rgba(255,255,255,0.15)" : "#2563EB",
borderRadius: 8,
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3"/>
<rect x="9" y="11" width="14" height="10" rx="2"/>
<circle cx="12" cy="21" r="1"/>
<circle cx="20" cy="21" r="1"/>
</svg>
</div>
<span style={{
fontWeight: 700,
fontSize: 15,
color: white ? "#FFFFFF" : "#0F172A",
}}>
Slash.sa
</span>
</div>
);
export default Logo;

View File

@@ -0,0 +1,236 @@
import * as yup from "yup";
// ── Enums (mirror backend) ────────────────────────────────────────────────────
const INSURANCE_STATUSES = ["Valid", "Expired", "NotInsured"] as const;
const CAR_STATUSES = ["Active", "InMaintenance", "InTrip", "Inactive"] as const;
const IMPOUND_STATUSES = ["None", "Impounded"] as const;
const IMAGE_STAGES = ["BEFORE", "AFTER", "GENERAL"] as const;
// ── Shared date helper ────────────────────────────────────────────────────────
// <input type="date"> gives "YYYY-MM-DD"; validate as a real date string
const dateString = yup
.string()
.test(
"valid-date",
"تاريخ غير صالح",
(val) => {
if (!val) return true; // optional
return !isNaN(Date.parse(val));
},
)
;
// ── Create schema ─────────────────────────────────────────────────────────────
export const createCarSchema = yup.object({
// Required
manufacturer: yup
.string()
.required("الشركة المصنعة مطلوبة")
.min(2, "اسم الشركة يجب أن يكون حرفين على الأقل"),
model: yup
.string()
.required("الموديل مطلوب")
.min(1, "الموديل مطلوب"),
year: yup
.number()
.typeError("سنة الصنع يجب أن تكون رقماً")
.required("سنة الصنع مطلوبة")
.integer("سنة الصنع يجب أن تكون رقماً صحيحاً")
.min(1900, "سنة الصنع غير صالحة")
.max(new Date().getFullYear() + 1, "سنة الصنع غير صالحة"),
plateNumber: yup
.string()
.required("رقم اللوحة مطلوب")
.min(1, "رقم اللوحة مطلوب"),
plateLetters: yup
.string()
.required("حروف اللوحة مطلوبة")
.min(1, "حروف اللوحة مطلوبة"),
registrationNumber: yup.string().required("رقم التسجيل مطلوب ").min(4,"رقم التسجيل لا يقل عن 4 ارقام"),
vinNumber: yup.string().required(" رقم الهيكل مطلوب").min(4,"رقم الهيكل لا يقل عن 4 ارقام"),
// Optional
color: yup.string().optional(),
plateType: yup.string().optional(),
ownerNationalId: yup.string().optional(),
branchId: yup
.string()
.uuid("معرّف الفرع غير صالح")
.optional(),
currentStatus: yup
.mixed<typeof CAR_STATUSES[number]>()
.oneOf([...CAR_STATUSES], "حالة المركبة غير صالحة")
.optional(),
insuranceStatus: yup
.mixed<typeof INSURANCE_STATUSES[number]>()
.oneOf([...INSURANCE_STATUSES], "حالة التأمين غير صالحة")
.optional(),
currentImpoundStatus: yup
.mixed<typeof IMPOUND_STATUSES[number]>()
.oneOf([...IMPOUND_STATUSES], "حالة الحجز غير صالحة")
.optional(),
registrationExpiryDate: dateString,
insuranceExpiryDate: dateString,
inspectionExpiryDate: dateString,
operationCardExpiry: dateString,
registrationIssueDate: dateString,
ownershipStartDate: dateString,
actualUserStartDate: dateString,
gpsDeviceId: yup.string().optional(),
operationCardNumber: yup.string().optional(),
capacity: yup
.number()
.typeError("الطاقة الاستيعابية يجب أن تكون رقماً")
.integer("الطاقة الاستيعابية يجب أن تكون رقماً صحيحاً")
.min(0, "الطاقة الاستيعابية يجب أن تكون 0 أو أكثر")
.optional(),
weight: yup
.number()
.typeError("الوزن يجب أن يكون رقماً")
.integer("الوزن يجب أن يكون رقماً صحيحاً")
.min(0, "الوزن يجب أن يكون 0 أو أكثر")
.optional(),
});
// ── Update schema (all optional except enums still validated) ─────────────────
export const updateCarSchema = yup.object({
manufacturer: yup
.string()
.min(2, "اسم الشركة يجب أن يكون حرفين على الأقل")
.optional(),
model: yup.string().optional(),
year: yup
.number()
.typeError("سنة الصنع يجب أن تكون رقماً")
.integer("سنة الصنع يجب أن تكون رقماً صحيحاً")
.min(1900, "سنة الصنع غير صالحة")
.max(new Date().getFullYear() + 1, "سنة الصنع غير صالحة")
.optional(),
plateNumber: yup.string().optional(),
plateLetters: yup.string().optional(),
color: yup.string().optional(),
plateType: yup.string().optional(),
registrationNumber: yup.string().optional(),
vinNumber: yup.string().optional(),
ownerNationalId: yup.string().optional(),
branchId: yup
.string()
.uuid("معرّف الفرع غير صالح")
.optional(),
currentStatus: yup
.mixed<typeof CAR_STATUSES[number]>()
.oneOf([...CAR_STATUSES], "حالة المركبة غير صالحة")
.optional(),
insuranceStatus: yup
.mixed<typeof INSURANCE_STATUSES[number]>()
.oneOf([...INSURANCE_STATUSES], "حالة التأمين غير صالحة")
.optional(),
currentImpoundStatus: yup
.mixed<typeof IMPOUND_STATUSES[number]>()
.oneOf([...IMPOUND_STATUSES], "حالة الحجز غير صالحة")
.optional(),
isActive: yup.boolean().optional(),
registrationExpiryDate: dateString,
insuranceExpiryDate: dateString,
inspectionExpiryDate: dateString,
operationCardExpiry: dateString,
registrationIssueDate: dateString,
ownershipStartDate: dateString,
actualUserStartDate: dateString,
gpsDeviceId: yup.string().optional(),
operationCardNumber: yup.string().optional(),
capacity: yup
.number()
.typeError("الطاقة الاستيعابية يجب أن تكون رقماً")
.integer("الطاقة الاستيعابية يجب أن تكون رقماً صحيحاً")
.min(0, "الطاقة الاستيعابية يجب أن تكون 0 أو أكثر")
.optional(),
weight: yup
.number()
.typeError("الوزن يجب أن يكون رقماً")
.integer("الوزن يجب أن يكون رقماً صحيحاً")
.min(0, "الوزن يجب أن يكون 0 أو أكثر")
.optional(),
});
// ── Car image schema (from carImage.validator.js) ─────────────────────────────
export const addCarImagesSchema = yup.object({
stage: yup
.mixed<typeof IMAGE_STAGES[number]>()
.oneOf([...IMAGE_STAGES], "مرحلة الصورة غير صالحة")
.optional(),
maintenanceId: yup
.string()
.uuid("معرّف الصيانة غير صالح")
.optional(),
});
// ── Infer form error shape ────────────────────────────────────────────────────
export type CarSchemaErrors = Partial<
Record<
| "manufacturer"
| "model"
| "year"
| "color"
| "plateNumber"
| "plateLetters"
| "plateType"
| "registrationNumber"
| "vinNumber"
| "ownerNationalId"
| "branchId"
| "currentStatus"
| "insuranceStatus"
| "currentImpoundStatus"
| "isActive"
| "registrationExpiryDate"
| "insuranceExpiryDate"
| "inspectionExpiryDate"
| "operationCardExpiry"
| "registrationIssueDate"
| "ownershipStartDate"
| "actualUserStartDate"
| "gpsDeviceId"
| "operationCardNumber"
| "capacity"
| "weight"
| "stage"
| "maintenanceId",
string
>
>;

View File

@@ -0,0 +1,207 @@
import * as yup from "yup"
import type { NationalIdType, DriverCardType } from "../src/types/driver";
// ── Create schema ─────────────────────────────────────────────────────────────
export const createDriverSchema = yup.object({
// Required
name: yup
.string()
.required("الاسم مطلوب")
.min(2, "الاسم يجب أن يكون حرفين على الأقل"),
phone: yup
.string()
.required("رقم الجوال مطلوب")
.matches(
/^(\+966|966|0)?5[0-9]{8}$/,
"رقم الجوال غير صالح — يجب أن يكون رقم سعودي صحيح",
),
// Optional
email: yup
.string()
.email("البريد الإلكتروني غير صالح")
.optional(),
address: yup
.string()
.min(5, "العنوان يجب أن يكون 5 أحرف على الأقل")
.optional(),
nationality: yup
.string()
.min(5, "الجنسية يجب أن تكون 5 أحرف على الأقل")
.optional(),
nationalIdType: yup
.mixed<NationalIdType>()
.oneOf(["NationalID", "Iqama", "Passport"], "نوع الهوية غير صالح")
.optional(),
gosiNumber: yup.string().optional(),
licenseNumber: yup.string().optional(),
licenseType: yup.string().optional(),
licenseExpiry: yup
.string()
.test(
"not-in-past",
"تاريخ انتهاء الرخصة يجب أن يكون اليوم أو في المستقبل",
(val) => {
if (!val) return true; // optional
return new Date(val) >= new Date(new Date().toDateString());
},
)
.optional(),
driverCardNumber: yup.string().optional(),
driverCardType: yup
.mixed<DriverCardType>()
.oneOf(
["Temporary", "Seasonal", "Annual", "Restricted"],
"نوع بطاقة السائق غير صالح",
)
.optional(),
driverCardExpiry: yup
.string()
.test(
"not-in-past",
"تاريخ انتهاء بطاقة السائق يجب أن يكون اليوم أو في المستقبل",
(val) => {
if (!val) return true;
return new Date(val) >= new Date(new Date().toDateString());
},
)
.optional(),
driverType: yup.string().optional(),
branchId: yup
.string()
.uuid("معرّف الفرع غير صالح")
.optional(),
});
// ── Update schema (same rules, all optional except they don't change) ─────────
export const updateDriverSchema = yup.object({
name: yup
.string()
.min(2, "الاسم يجب أن يكون حرفين على الأقل")
.optional(),
phone: yup
.string()
.matches(
/^(\+966|966|0)?5[0-9]{8}$/,
"رقم الجوال غير صالح — يجب أن يكون رقم سعودي صحيح",
)
.optional(),
email: yup
.string()
.email("البريد الإلكتروني غير صالح")
.optional(),
address: yup
.string()
.min(5, "العنوان يجب أن يكون 5 أحرف على الأقل")
.optional(),
nationality: yup
.string()
.min(5, "الجنسية يجب أن تكون 5 أحرف على الأقل")
.optional(),
nationalIdType: yup
.mixed<NationalIdType>()
.oneOf(["NationalID", "Iqama", "Passport"], "نوع الهوية غير صالح")
.optional(),
gosiNumber: yup.string().optional(),
licenseNumber: yup.string().optional(),
licenseType: yup.string().optional(),
licenseExpiry: yup
.string()
.test(
"not-in-past",
"تاريخ انتهاء الرخصة يجب أن يكون اليوم أو في المستقبل",
(val) => {
if (!val) return true;
return new Date(val) >= new Date(new Date().toDateString());
},
)
.optional(),
driverCardNumber: yup.string().optional(),
driverCardType: yup
.mixed<DriverCardType>()
.oneOf(
["Temporary", "Seasonal", "Annual", "Restricted"],
"نوع بطاقة السائق غير صالح",
)
.optional(),
driverCardExpiry: yup
.string()
.test(
"not-in-past",
"تاريخ انتهاء بطاقة السائق يجب أن يكون اليوم أو في المستقبل",
(val) => {
if (!val) return true;
return new Date(val) >= new Date(new Date().toDateString());
},
)
.optional(),
driverType: yup.string().optional(),
branchId: yup
.string()
.uuid("معرّف الفرع غير صالح")
.optional(),
status: yup
.mixed<"Active" | "Inactive" | "InTrip" | "Suspended">()
.oneOf(
["Active", "Inactive", "InTrip", "Suspended"],
"حالة السائق غير صالحة",
)
.optional(),
reason: yup.string().optional(),
});
// ── Infer form error shape ────────────────────────────────────────────────────
export type DriverSchemaErrors = Partial<
Record<
| "name"
| "phone"
| "email"
| "address"
| "nationality"
| "nationalIdType"
| "gosiNumber"
| "licenseNumber"
| "licenseType"
| "licenseExpiry"
| "driverCardNumber"
| "driverCardType"
| "driverCardExpiry"
| "driverType"
| "branchId"
| "status"
| "reason",
string
>
>;

View File

@@ -0,0 +1,194 @@
import * as yup from "yup";
import type { TripStatus } from "@/src/types/trip";
const TRIP_STATUSES: TripStatus[] = [
"Scheduled",
"InProgress",
"Completed",
"Cancelled",
];
// NOTE: Unlike driver_validator.ts, there is no actual backend trip validator
// file in this handoff. The rules below come from trip_api_reference.html
// (marked "// doc") plus a few sensible defaults that are NOT explicitly
// stated in the reference (marked "// inferred"). Verify the "inferred" ones
// against the real backend validator before relying on them.
// ── Create schema ─────────────────────────────────────────────────────────────
export const createTripSchema = yup.object({
// Required
title: yup
.string()
.required("عنوان الرحلة مطلوب")
.min(3, "عنوان الرحلة يجب أن يكون 3 أحرف على الأقل"), // doc
driverId: yup
.string()
.required("السائق مطلوب")
.uuid("معرّف السائق غير صالح"), // doc: UUID, driver must be status=Active (enforced server-side)
carId: yup
.string()
.required("السيارة مطلوبة")
.uuid("معرّف السيارة غير صالح"), // doc: UUID, car must be status=Active (enforced server-side)
// Optional
startTime: yup.string().optional(), // doc: ISO 8601, default now()
branchId: yup.string().required("الفرع مطلوبة").uuid("معرّف الفرع غير صالح"),// doc
endTime: yup
.string()
.optional()
.test(
"after-start",
"وقت الانتهاء يجب أن يكون بعد وقت البدء",
function (val) {
if (!val) return true;
const { startTime } = this.parent;
if (!startTime) return true;
return new Date(val) > new Date(startTime); // inferred
},
),
status: yup
.mixed<TripStatus>()
.oneOf(TRIP_STATUSES, "حالة الرحلة غير صالحة")
.optional(), // doc: default Scheduled
collectedCount: yup
.number()
.typeError("القيمة يجب أن تكون رقم")
.integer("القيمة يجب أن تكون رقم صحيح")
.min(0, "القيمة لا يمكن أن تكون سالبة") // inferred
.optional(),
deliveredCount: yup
.number()
.typeError("القيمة يجب أن تكون رقم")
.integer("القيمة يجب أن تكون رقم صحيح")
.min(0, "القيمة لا يمكن أن تكون سالبة") // inferred
.optional(),
returnedCount: yup
.number()
.typeError("القيمة يجب أن تكون رقم")
.integer("القيمة يجب أن تكون رقم صحيح")
.min(0, "القيمة لا يمكن أن تكون سالبة") // inferred
.optional(),
totalCashCollected: yup
.number()
.typeError("القيمة يجب أن تكون رقم")
.min(0, "القيمة لا يمكن أن تكون سالبة") // inferred
.optional(), // doc: number | string -> Decimal server-side
notes: yup
.string()
.min(2, "الملاحظات يجب أن تكون حرفين على الأقل") // doc
.optional(),
endReason: yup
.string()
.min(2, "سبب الانتهاء يجب أن يكون حرفين على الأقل") // doc
.optional(),
});
// ── Update schema (same rules, all optional + reason) ─────────────────────────
export const updateTripSchema = yup.object({
title: yup
.string()
.min(3, "عنوان الرحلة يجب أن يكون 3 أحرف على الأقل")
.optional(),
driverId: yup.string().uuid("معرّف السائق غير صالح").optional(), // doc: must be Active
carId: yup.string().uuid("معرّف السيارة غير صالح").optional(), // doc: must be Active
status: yup
.mixed<TripStatus>()
.oneOf(TRIP_STATUSES, "حالة الرحلة غير صالحة")
.optional(),
startTime: yup.string().optional(),
endTime: yup
.string()
.optional()
.test(
"after-start",
"وقت الانتهاء يجب أن يكون بعد وقت البدء",
function (val) {
if (!val) return true;
const { startTime } = this.parent;
if (!startTime) return true;
return new Date(val) > new Date(startTime); // inferred
},
),
branchId: yup.string().uuid("معرّف الفرع غير صالح").optional(),
collectedCount: yup
.number()
.typeError("القيمة يجب أن تكون رقم")
.integer("القيمة يجب أن تكون رقم صحيح")
.min(0, "القيمة لا يمكن أن تكون سالبة")
.optional(),
deliveredCount: yup
.number()
.typeError("القيمة يجب أن تكون رقم")
.integer("القيمة يجب أن تكون رقم صحيح")
.min(0, "القيمة لا يمكن أن تكون سالبة")
.optional(),
returnedCount: yup
.number()
.typeError("القيمة يجب أن تكون رقم")
.integer("القيمة يجب أن تكون رقم صحيح")
.min(0, "القيمة لا يمكن أن تكون سالبة")
.optional(),
totalCashCollected: yup
.number()
.typeError("القيمة يجب أن تكون رقم")
.min(0, "القيمة لا يمكن أن تكون سالبة")
.optional(),
notes: yup
.string()
.min(2, "الملاحظات يجب أن تكون حرفين على الأقل")
.optional(),
endReason: yup
.string()
.min(2, "سبب الانتهاء يجب أن يكون حرفين على الأقل")
.optional(),
// Update-only: reason for reassigning driver/car (recorded in history)
reason: yup.string().optional(), // doc: no explicit min length stated
});
// ── Infer form error shape ────────────────────────────────────────────────────
export type TripSchemaErrors = Partial<
Record<
| "title"
| "driverId"
| "carId"
| "status"
| "startTime"
| "endTime"
| "branchId"
| "collectedCount"
| "deliveredCount"
| "returnedCount"
| "totalCashCollected"
| "notes"
| "endReason"
| "reason",
string
>
>;

View File

@@ -0,0 +1,90 @@
import * as yup from "yup";
// ── Phone regex — same as backend ─────────────────────────────────────────────
const SAUDI_PHONE_RE = /^(\+966|966|0)?5[0-9]{8}$/;
// ── Create schema ─────────────────────────────────────────────────────────────
export const createUserSchema = yup.object({
name: yup
.string()
.required("الاسم الكامل مطلوب")
.min(2, "الاسم يجب أن يكون حرفين على الأقل"),
phone: yup
.string()
.required("رقم الهاتف مطلوب")
.matches(SAUDI_PHONE_RE, "رقم الجوال غير صالح — يجب أن يكون رقم سعودي صحيح"),
email: yup
.string()
.email("البريد الإلكتروني غير صالح")
.optional(),
password: yup
.string()
.required("كلمة المرور مطلوبة")
.min(8, "كلمة المرور يجب أن تكون 8 أحرف على الأقل"),
roleId: yup
.string()
.required("الدور مطلوب")
.uuid("معرّف الدور غير صالح"),
branchId: yup
.string()
.uuid("معرّف الفرع غير صالح")
.optional(),
});
// ── Update schema ─────────────────────────────────────────────────────────────
export const updateUserSchema = yup.object({
name: yup
.string()
.min(2, "الاسم يجب أن يكون حرفين على الأقل")
.optional(),
phone: yup
.string()
.matches(SAUDI_PHONE_RE, "رقم الجوال غير صالح — يجب أن يكون رقم سعودي صحيح")
.optional(),
email: yup
.string()
.email("البريد الإلكتروني غير صالح")
.optional(),
// password is optional on update — only sent if user wants to change it
password: yup
.string()
.min(8, "كلمة المرور يجب أن تكون 8 أحرف على الأقل")
.optional(),
isActive: yup.boolean().optional(),
roleId: yup
.string()
.uuid("معرّف الدور غير صالح")
.optional(),
branchId: yup
.string()
.uuid("معرّف الفرع غير صالح")
.optional(),
});
// ── Infer form error shape ────────────────────────────────────────────────────
export type UserSchemaErrors = Partial<
Record<
| "name"
| "phone"
| "email"
| "password"
| "roleId"
| "branchId"
| "isActive",
string
>
>;