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:
0
src/Components/Admen/admen.tsx
Normal file
0
src/Components/Admen/admen.tsx
Normal file
392
src/Components/Client/Addressformmodal.tsx
Normal file
392
src/Components/Client/Addressformmodal.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import type {
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
ClientAddressFormErrors,
|
||||
} from "@/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>
|
||||
);
|
||||
}
|
||||
175
src/Components/Client/ClientAddressForm.tsx
Normal file
175
src/Components/Client/ClientAddressForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/Components/Client/ClientForm.tsx
Normal file
135
src/Components/Client/ClientForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
353
src/Components/Client/Clientformmodal.tsx
Normal file
353
src/Components/Client/Clientformmodal.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import type { Client, ClientFormData, ClientFormErrors } from "@/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 = "رقم هاتف غير صالح (10–15 رقم)";
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientFormModalProps {
|
||||
editClient: Client | null; // null = create mode, Client = edit mode
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function ClientFormModal({
|
||||
editClient,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ClientFormModalProps) {
|
||||
const isNew = editClient === null;
|
||||
|
||||
const [form, setForm] = useState<ClientFormData>({
|
||||
name: editClient?.name ?? "",
|
||||
email: editClient?.email ?? "",
|
||||
phone: editClient?.phone ?? "",
|
||||
taxId: editClient?.taxId ?? "",
|
||||
notes: editClient?.notes ?? "",
|
||||
});
|
||||
const [errors, setErrors] = useState<ClientFormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { firstInputRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const set = (field: keyof ClientFormData) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||
if (errors[field]) setErrors((p) => ({ ...p, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate(form);
|
||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
const ok = await onSubmit(form, isNew);
|
||||
setSaving(false);
|
||||
if (ok) onClose();
|
||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||
};
|
||||
|
||||
const inputStyle = (field: keyof ClientFormErrors): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="client-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.3em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إضافة عميل" : "تعديل عميل"}
|
||||
</p>
|
||||
<h2
|
||||
id="client-modal-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{isNew ? "عميل جديد" : editClient?.name}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
color: "var(--color-text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<label style={S.label}>
|
||||
اسم العميل *
|
||||
<input
|
||||
ref={firstInputRef}
|
||||
style={inputStyle("name")}
|
||||
value={form.name}
|
||||
onChange={set("name")}
|
||||
placeholder="شركة لوجي فلو للتوصيل"
|
||||
autoComplete="organization"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
||||
</label>
|
||||
|
||||
{/* Email + Phone */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<label style={S.label}>
|
||||
البريد الإلكتروني *
|
||||
<input
|
||||
style={inputStyle("email")}
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set("email")}
|
||||
placeholder="info@company.sa"
|
||||
autoComplete="email"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.email && <span style={S.errorText}>{errors.email}</span>}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
رقم الهاتف *
|
||||
<input
|
||||
style={inputStyle("phone")}
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={set("phone")}
|
||||
placeholder="+966 5x xxx xxxx"
|
||||
autoComplete="tel"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tax ID */}
|
||||
<label style={S.label}>
|
||||
الرقم الضريبي (اختياري)
|
||||
<input
|
||||
style={S.input}
|
||||
value={form.taxId}
|
||||
onChange={set("taxId")}
|
||||
placeholder="300xxxxxxxxx"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Notes */}
|
||||
<label style={S.label}>
|
||||
ملاحظات (اختياري)
|
||||
<textarea
|
||||
style={{
|
||||
...S.input,
|
||||
height: 80,
|
||||
padding: "0.5rem 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
value={form.notes}
|
||||
onChange={set("notes")}
|
||||
placeholder="أي معلومات إضافية عن العميل…"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.25rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: saving
|
||||
? "var(--color-brand-400)"
|
||||
: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{saving && <Spinner size="sm" className="text-white" />}
|
||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
403
src/Components/Client/Clienttable.tsx
Normal file
403
src/Components/Client/Clienttable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/Components/Client/Deleteconfirmmodal.tsx
Normal file
171
src/Components/Client/Deleteconfirmmodal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/Components/Client/Toast.tsx
Normal file
64
src/Components/Client/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/Components/Client/index.ts
Normal file
5
src/Components/Client/index.ts
Normal 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";
|
||||
0
src/Components/Client_Adress/clientAdress.tsx
Normal file
0
src/Components/Client_Adress/clientAdress.tsx
Normal file
100
src/Components/Driver/DriverDeleteModal.tsx
Normal file
100
src/Components/Driver/DriverDeleteModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
565
src/Components/Driver/DriverDetailPanel.tsx
Normal file
565
src/Components/Driver/DriverDetailPanel.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
693
src/Components/Driver/DriverFormModal.tsx
Normal file
693
src/Components/Driver/DriverFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/Components/Driver/DriverPhotos.tsx
Normal file
84
src/Components/Driver/DriverPhotos.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/Components/Driver/index.ts
Normal file
4
src/Components/Driver/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DriverFormModal } from "./DriverFormModal";
|
||||
export { DriverDeleteModal } from "./DriverDeleteModal";
|
||||
export { DriverDetailPanel } from "./DriverDetailPanel";
|
||||
export { PhotoCard } from "./DriverPhotos";
|
||||
163
src/Components/Driver_Report/driverReport.tsx
Normal file
163
src/Components/Driver_Report/driverReport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
src/Components/Home/home.tsx
Normal file
0
src/Components/Home/home.tsx
Normal file
210
src/Components/Order/order.tsx
Normal file
210
src/Components/Order/order.tsx
Normal 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;
|
||||
99
src/Components/Trip/Tripdeletemodal.tsx
Normal file
99
src/Components/Trip/Tripdeletemodal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
732
src/Components/Trip/Tripformmodal.tsx
Normal file
732
src/Components/Trip/Tripformmodal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/Components/Trip/index.ts
Normal file
2
src/Components/Trip/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TripFormModal } from "./Tripformmodal";
|
||||
export { TripDeleteModal } from "./Tripdeletemodal";
|
||||
120
src/Components/Trip_Report/Tripreportpanel.tsx
Normal file
120
src/Components/Trip_Report/Tripreportpanel.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/Components/UI/Alert.tsx
Normal file
59
src/Components/UI/Alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/Components/UI/Badge.tsx
Normal file
22
src/Components/UI/Badge.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
interface BadgeProps {
|
||||
label: string;
|
||||
color?: "indigo" | "green" | "amber" | "red" | "slate";
|
||||
}
|
||||
|
||||
const BADGE_COLORS: Record<string, string> = {
|
||||
indigo: "bg-indigo-100 text-indigo-700",
|
||||
green: "bg-emerald-100 text-emerald-700",
|
||||
amber: "bg-amber-100 text-amber-700",
|
||||
red: "bg-red-100 text-red-700",
|
||||
slate: "bg-slate-100 text-slate-600",
|
||||
};
|
||||
|
||||
export function Badge({ label, color = "slate" }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold ${BADGE_COLORS[color]}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
74
src/Components/UI/Button.tsx
Normal file
74
src/Components/UI/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/Components/UI/ConfirmDialog.tsx
Normal file
51
src/Components/UI/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/Components/UI/EmptyState.tsx
Normal file
17
src/Components/UI/EmptyState.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon = "📋", title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<span className="text-5xl" aria-hidden="true">{icon}</span>
|
||||
<h3 className="text-base font-semibold text-slate-700">{title}</h3>
|
||||
{description && <p className="text-sm text-slate-500 max-w-xs">{description}</p>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/Components/UI/Input.tsx
Normal file
91
src/Components/UI/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/Components/UI/ModalProps.tsx
Normal file
130
src/Components/UI/ModalProps.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/Components/UI/PageHeader.tsx
Normal file
60
src/Components/UI/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/Components/UI/Select.tsx
Normal file
54
src/Components/UI/Select.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
80
src/Components/UI/Spinner.tsx
Normal file
80
src/Components/UI/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/Components/UI/Textarea.tsx
Normal file
64
src/Components/UI/Textarea.tsx
Normal 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
101
src/Components/UI/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/Components/UI/index.ts
Normal file
24
src/Components/UI/index.ts
Normal 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";
|
||||
58
src/Components/User/DeleteConfirmModal.tsx
Normal file
58
src/Components/User/DeleteConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/Components/User/Toast.tsx
Normal file
56
src/Components/User/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
src/Components/User/UserDetailModal.tsx
Normal file
241
src/Components/User/UserDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
src/Components/User/UserFormModal.tsx
Normal file
222
src/Components/User/UserFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/Components/User/UserTable.tsx
Normal file
167
src/Components/User/UserTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/Components/User/index.ts
Normal file
5
src/Components/User/index.ts
Normal 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";
|
||||
86
src/Components/car/CarDeleteModal.tsx
Normal file
86
src/Components/car/CarDeleteModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
src/Components/car/CarDetailPanel.tsx
Normal file
256
src/Components/car/CarDetailPanel.tsx
Normal 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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
787
src/Components/car/CarFormModal.tsx
Normal file
787
src/Components/car/CarFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
src/Components/car/CarImageGallery.tsx
Normal file
216
src/Components/car/CarImageGallery.tsx
Normal 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 }}>
|
||||
اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
4
src/Components/car/index.ts
Normal file
4
src/Components/car/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { CarFormModal } from "./CarFormModal";
|
||||
export { CarDetailPanel } from "./CarDetailPanel";
|
||||
export { CarDeleteModal } from "./CarDeleteModal";
|
||||
export { CarImageGallery } from "./CarImageGallery";
|
||||
130
src/Components/role/DeleteRoleModal.tsx
Normal file
130
src/Components/role/DeleteRoleModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
375
src/Components/role/RoleDetailModal.tsx
Normal file
375
src/Components/role/RoleDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
510
src/Components/role/RoleFormModal.tsx
Normal file
510
src/Components/role/RoleFormModal.tsx
Normal 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)" }}>
|
||||
لا توجد نتائج لـ "{searchPerm}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
300
src/Components/role/RoleTable.tsx
Normal file
300
src/Components/role/RoleTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/Components/role/RoleToast.tsx
Normal file
57
src/Components/role/RoleToast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/Components/role/index.ts
Normal file
5
src/Components/role/index.ts
Normal 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
216
src/hooks/useCars.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
184
src/hooks/useClientAddresses.ts
Normal file
184
src/hooks/useClientAddresses.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
src/hooks/useClientOrders.ts
Normal file
45
src/hooks/useClientOrders.ts
Normal 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
191
src/hooks/useClients.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
49
src/hooks/useDashboardSummary.ts
Normal file
49
src/hooks/useDashboardSummary.ts
Normal 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
211
src/hooks/useDriver.ts
Normal 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
181
src/hooks/useOrder.ts
Normal 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
284
src/hooks/useRole.ts
Normal 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
227
src/hooks/useTrip.ts
Normal 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
163
src/hooks/useUser.ts
Normal 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
57
src/lib/api.ts
Normal 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
107
src/lib/auth.ts
Normal 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
41
src/lib/formatters.ts
Normal 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
42
src/lib/order-status.ts
Normal 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
53
src/lib/session.ts
Normal 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
11
src/lib/utils.ts
Normal 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(" ");
|
||||
}
|
||||
118
src/middleware/middleware.ts
Normal file
118
src/middleware/middleware.ts
Normal 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
72
src/services/api.ts
Normal 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);
|
||||
|
||||
|
||||
28
src/services/auth.service.ts
Normal file
28
src/services/auth.service.ts
Normal 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
129
src/services/car.service.ts
Normal 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),
|
||||
};
|
||||
48
src/services/client.service.ts
Normal file
48
src/services/client.service.ts
Normal 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;
|
||||
}
|
||||
49
src/services/clientAddress.service.ts
Normal file
49
src/services/clientAddress.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
7
src/services/dashboard.service.ts
Normal file
7
src/services/dashboard.service.ts
Normal 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),
|
||||
};
|
||||
273
src/services/driver.service.ts
Normal file
273
src/services/driver.service.ts
Normal 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
11
src/services/index.ts
Normal 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";
|
||||
45
src/services/order.service.ts
Normal file
45
src/services/order.service.ts
Normal 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"),
|
||||
};
|
||||
95
src/services/role.service.ts
Normal file
95
src/services/role.service.ts
Normal 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),
|
||||
|
||||
};
|
||||
109
src/services/trip.service.ts
Normal file
109
src/services/trip.service.ts
Normal 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),
|
||||
};
|
||||
71
src/services/user.service.ts
Normal file
71
src/services/user.service.ts
Normal 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
140
src/styles/tokens.css
Normal 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
16
src/types/auth.ts
Normal 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
4
src/types/branch.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Branch {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
225
src/types/car.ts
Normal file
225
src/types/car.ts
Normal 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
129
src/types/client.ts
Normal 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
41
src/types/dashboard.ts
Normal 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
127
src/types/driver.ts
Normal 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
97
src/types/order.ts
Normal 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
4
src/types/role.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
156
src/types/trip.ts
Normal file
156
src/types/trip.ts
Normal 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
66
src/types/user.ts
Normal 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
51
src/utils/helperFun.ts
Normal 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
33
src/utils/logo.tsx
Normal 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;
|
||||
236
src/validations/car.validator.ts
Normal file
236
src/validations/car.validator.ts
Normal 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
|
||||
>
|
||||
>;
|
||||
207
src/validations/driver.validator.ts
Normal file
207
src/validations/driver.validator.ts
Normal 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
|
||||
>
|
||||
>;
|
||||
194
src/validations/trip.validator.ts
Normal file
194
src/validations/trip.validator.ts
Normal 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
|
||||
>
|
||||
>;
|
||||
90
src/validations/user.validator.ts
Normal file
90
src/validations/user.validator.ts
Normal 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
|
||||
>
|
||||
>;
|
||||
Reference in New Issue
Block a user