The trip files have been reorganized. Errors related to vehicle and driver editing have been resolved, archive display issues fixed, and model data display standardized across the project; order and trip-related issues have also been addressed.
This commit is contained in:
0
src/Components/Admen/admen.tsx
Normal file
0
src/Components/Admen/admen.tsx
Normal file
58
src/Components/Branch/DeleteConfirmModal.tsx
Normal file
58
src/Components/Branch/DeleteConfirmModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
branch: Branch;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({ branch, 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="branch-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" }}
|
||||
>
|
||||
{/* icon delete */}
|
||||
<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="branch-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)" }}>{branch.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>
|
||||
);
|
||||
}
|
||||
91
src/Components/Car_Maintanance/CarMaintananceDeleteModal.tsx
Normal file
91
src/Components/Car_Maintanance/CarMaintananceDeleteModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { fmtCost } from "@/src/types/carMaintanance";
|
||||
import type { CarMaintenance } from "@/src/types/carMaintanance";
|
||||
|
||||
interface CarMaintenanceDeleteModalProps {
|
||||
record: CarMaintenance;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function CarMaintenanceDeleteModal({
|
||||
record,
|
||||
deleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: CarMaintenanceDeleteModalProps) {
|
||||
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="maintenance-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="maintenance-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)" }}>
|
||||
{record.reason}
|
||||
</strong>
|
||||
{" "}(
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{fmtCost(record.cost)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
5
src/Components/Client/Toast.tsx
Normal file
5
src/Components/Client/Toast.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
// Re-export the canonical implementation so existing imports keep working.
|
||||
export { Toast } from "@/src/Components/UI";
|
||||
export type { ToastNotification as Notification } from "@/src/Components/UI";
|
||||
258
src/Components/Client_Adress/AddressDetails.tsx
Normal file
258
src/Components/Client_Adress/AddressDetails.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function labelIcon(label: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"فوترة": "💳",
|
||||
"شحن": "📦",
|
||||
"المقر الرئيسي": "🏢",
|
||||
"فرع": "🏬",
|
||||
"مستودع": "🏭",
|
||||
billing: "💳",
|
||||
shipping: "📦",
|
||||
"head office": "🏢",
|
||||
branch: "🏬",
|
||||
warehouse: "🏭",
|
||||
};
|
||||
return map[label.toLowerCase()] ?? "📍";
|
||||
}
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────────────────────
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
ltr = false,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
ltr?: boolean;
|
||||
}) {
|
||||
if (!value) return 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: 700,
|
||||
color: "var(--color-text-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
dir={ltr ? "ltr" : "rtl"}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-primary)",
|
||||
fontFamily: ltr ? "var(--font-mono)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Card header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }} aria-hidden="true">{icon}</span>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-secondary)",
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{ padding: "0 1.5rem" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ────────────────────────────────────────────────────────
|
||||
|
||||
interface AddressDetailsProps {
|
||||
address: ClientAddress;
|
||||
}
|
||||
|
||||
export function AddressDetails({ address }: AddressDetailsProps) {
|
||||
const { details, contactPerson, location } = address;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{/* Identity card — label + branch */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
padding: "1.25rem 1.5rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
}}
|
||||
dir="rtl"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: "var(--radius-lg)",
|
||||
background: "var(--color-brand-50)",
|
||||
border: "1px solid var(--color-brand-100)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 24,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{labelIcon(address.label)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{address.label}
|
||||
</p>
|
||||
{address.branchName && (
|
||||
<p
|
||||
style={{
|
||||
margin: "3px 0 0",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{address.branchName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address details */}
|
||||
<SectionCard title="تفاصيل العنوان" icon="🗺️">
|
||||
<DetailRow label="الشارع" value={details.street} />
|
||||
<DetailRow label="المدينة" value={details.city} />
|
||||
<DetailRow label="المنطقة / الولاية" value={details.state} />
|
||||
<DetailRow label="الحي" value={details.district} />
|
||||
<DetailRow label="رقم المبنى" value={details.buildingNo} ltr />
|
||||
<DetailRow label="رقم الوحدة" value={details.unitNo} ltr />
|
||||
<DetailRow label="الرقم الإضافي" value={details.additionalNo} ltr />
|
||||
<DetailRow label="الرمز البريدي" value={details.zipCode} ltr />
|
||||
<DetailRow label="الشقة / الطابق" value={details.apartment} />
|
||||
<DetailRow label="الدولة" value={details.country} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Contact person — only when present */}
|
||||
{(contactPerson?.name || contactPerson?.phone) && (
|
||||
<SectionCard title="جهة الاتصال" icon="👤">
|
||||
<DetailRow label="الاسم" value={contactPerson.name} />
|
||||
<DetailRow label="رقم الهاتف" value={contactPerson.phone} ltr />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Coordinates */}
|
||||
{location?.coordinates && (
|
||||
<SectionCard title="الإحداثيات الجغرافية" icon="📡">
|
||||
<DetailRow
|
||||
label="خط الطول (Longitude)"
|
||||
value={String(location.coordinates[0])}
|
||||
ltr
|
||||
/>
|
||||
<DetailRow
|
||||
label="خط العرض (Latitude)"
|
||||
value={String(location.coordinates[1])}
|
||||
ltr
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* System metadata */}
|
||||
<SectionCard title="معلومات النظام" icon="🕐">
|
||||
<DetailRow
|
||||
label="تاريخ الإنشاء"
|
||||
value={new Date(address.createdAt).toLocaleString("ar-SA", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
/>
|
||||
<DetailRow
|
||||
label="آخر تحديث"
|
||||
value={new Date(address.updatedAt).toLocaleString("ar-SA", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
55
src/Components/Driver/DriverFormModalHelper.tsx
Normal file
55
src/Components/Driver/DriverFormModalHelper.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
|
||||
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)",
|
||||
};
|
||||
|
||||
export function FileInput({
|
||||
label,
|
||||
current,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
current: File | null;
|
||||
onChange: (f: File | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<label style={labelStyle}>
|
||||
{label}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
0
src/Components/Driver/driver.tsx
Normal file
0
src/Components/Driver/driver.tsx
Normal file
0
src/Components/Home/home.tsx
Normal file
0
src/Components/Home/home.tsx
Normal file
104
src/Components/Order/OrderDeleteModal.tsx
Normal file
104
src/Components/Order/OrderDeleteModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Order } from "@/src/types/order";
|
||||
|
||||
interface OrderDeleteModalProps {
|
||||
order: Order;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors DriverDeleteModal.tsx 1:1 — same alertdialog role, same
|
||||
* backdrop/click-outside-to-close behavior, same Escape-to-close effect,
|
||||
* same button layout. Only the copy and the identifying fields differ.
|
||||
*/
|
||||
export function OrderDeleteModal({ order, deleting, onCancel, onConfirm }: OrderDeleteModalProps) {
|
||||
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="order-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="order-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)" }}>{order.shipmentNumber}</strong>
|
||||
{" "}الخاص بـ{" "}
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{order.recipientName}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
551
src/Components/Order/OrderDetailBanel.tsx
Normal file
551
src/Components/Order/OrderDetailBanel.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Spinner, Alert } from "../UI";
|
||||
import { orderService } from "@/src/services/order.service";
|
||||
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
||||
|
||||
const ORDER_STATUS_MAP: Record<OrderStatus, { label: string; color: string; bg: string; border: string; dot: string }> = {
|
||||
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
|
||||
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE", dot: "#8B5CF6" },
|
||||
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
||||
Delivered: { label: "تم التسليم", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
|
||||
Returned: { label: "مُرتجع", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" },
|
||||
Cancelled: { label: "ملغي", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0", dot: "#94A3B8" },
|
||||
};
|
||||
|
||||
const PAY_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
Pending: { label: "معلَّق", color: "#D97706" },
|
||||
Paid: { label: "مدفوع", color: "#16A34A" },
|
||||
Failed: { label: "فشل", color: "#DC2626" },
|
||||
Refunded: { label: "مُسترجع", color: "#64748B" },
|
||||
};
|
||||
|
||||
const PAY_METHOD_LABEL: Record<string, string> = {
|
||||
Cash: "نقداً",
|
||||
Card: "بطاقة",
|
||||
Prepaid: "مدفوع مسبقاً",
|
||||
};
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function fmtAmount(n?: number | string | null): string {
|
||||
if (n == null || n === "") return "—";
|
||||
const num = Number(n);
|
||||
if (isNaN(num)) return "—";
|
||||
return `${num.toFixed(2)} ر.س`;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface OrderDetailPanelProps {
|
||||
orderId: string;
|
||||
onClose: () => void;
|
||||
onEdit: (order: Order) => void;
|
||||
onDelete: (order: Order) => void;
|
||||
onStatusChanged?: (order: Order) => void;
|
||||
}
|
||||
|
||||
export function OrderDetailPanel({
|
||||
orderId,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChanged,
|
||||
}: OrderDetailPanelProps) {
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [statusDraft, setStatusDraft] = useState<OrderStatus | "">("");
|
||||
const [statusReason, setStatusReason] = useState("");
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
|
||||
const loadOrder = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await orderService.getById(orderId);
|
||||
setOrder(data);
|
||||
setStatusDraft(data.currentStatus);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الطلب.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => { queueMicrotask(loadOrder); }, [loadOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
const handleStatusUpdate = useCallback(async () => {
|
||||
if (!order || !statusDraft || statusDraft === order.currentStatus) return;
|
||||
setUpdatingStatus(true);
|
||||
setStatusError(null);
|
||||
try {
|
||||
const payload: UpdateOrderStatusPayload = { status: statusDraft };
|
||||
if (statusReason) payload.reason = statusReason;
|
||||
const updated = await orderService.updateStatus(order.id, payload);
|
||||
setOrder(updated);
|
||||
setStatusReason("");
|
||||
onStatusChanged?.(updated);
|
||||
} catch (err: unknown) {
|
||||
setStatusError(err instanceof Error ? err.message : "تعذّر تحديث حالة الطلب.");
|
||||
} finally {
|
||||
setUpdatingStatus(false);
|
||||
}
|
||||
}, [order, statusDraft, statusReason, onStatusChanged]);
|
||||
|
||||
const statusConfig = order ? ORDER_STATUS_MAP[order.currentStatus] : null;
|
||||
const payStatus = order?.paymentStatus ? PAY_STATUS_MAP[order.paymentStatus] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 40,
|
||||
background: "rgba(15,23,42,0.45)",
|
||||
backdropFilter: "blur(2px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
border: "2px solid var(--color-brand-200)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand-600)" 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"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||
ملف الطلب
|
||||
</p>
|
||||
{order && (
|
||||
<h2 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "3px 0 0", fontFamily: "var(--font-mono)" }}>
|
||||
{order.shipmentNumber}
|
||||
</h2>
|
||||
)}
|
||||
{order?.trip?.tripNumber && (
|
||||
<p style={{ fontSize: 11, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)", margin: "2px 0 0" }}>
|
||||
رحلة: {order.trip.tripNumber}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{order && !loading && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
{payStatus && (
|
||||
<span style={{
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
padding: "0.3rem 0.875rem",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: payStatus.color,
|
||||
}}>
|
||||
{payStatus.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeading title="بيانات المستلم" />
|
||||
<DetailRow label="اسم المستلم" value={order.recipientName} />
|
||||
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
|
||||
<DetailRow label="العميل" value={order.client?.name ?? "—"} />
|
||||
|
||||
<SectionHeading title="بيانات الشحنة" />
|
||||
<DetailRow label="رقم الشحنة" value={order.shipmentNumber} mono />
|
||||
<DetailRow label="نوع الشحنة" value={order.type ?? "—"} />
|
||||
<DetailRow label="الكمية" value={String(order.quantity ?? "—")} />
|
||||
<DetailRow label="الوزن" value={order.weight != null ? `${order.weight} كجم` : "—"} />
|
||||
<DetailRow label="الرحلة" value={order.trip?.tripNumber ?? "—"} mono />
|
||||
|
||||
<SectionHeading title="بيانات الدفع" />
|
||||
<DetailRow label="طريقة الدفع" value={order.paymentMethod ? PAY_METHOD_LABEL[order.paymentMethod] ?? order.paymentMethod : "—"} />
|
||||
<DetailRow label="حالة الدفع" value={payStatus?.label ?? "—"} />
|
||||
<DetailRow label="الإجمالي الفرعي" value={fmtAmount(order.subTotal)} mono />
|
||||
<DetailRow label="ضريبة القيمة المضافة" value={fmtAmount(order.vatAmount)} mono />
|
||||
<DetailRow label="الإجمالي" value={fmtAmount(order.totalPrice)} mono />
|
||||
|
||||
{order.deliveryAddress?.details && Object.values(order.deliveryAddress.details).some(Boolean) && (
|
||||
<>
|
||||
<SectionHeading title="عنوان التسليم" />
|
||||
{order.deliveryAddress.details.city && <DetailRow label="المدينة" value={order.deliveryAddress.details.city} />}
|
||||
{order.deliveryAddress.details.district && <DetailRow label="الحي" value={order.deliveryAddress.details.district} />}
|
||||
{order.deliveryAddress.details.street && <DetailRow label="الشارع" value={order.deliveryAddress.details.street} />}
|
||||
{order.deliveryAddress.details.buildingNo && <DetailRow label="رقم المبنى" value={order.deliveryAddress.details.buildingNo} mono />}
|
||||
{order.deliveryAddress.details.unitNo && <DetailRow label="رقم الوحدة" value={order.deliveryAddress.details.unitNo} mono />}
|
||||
{order.deliveryAddress.details.zipCode && <DetailRow label="الرمز البريدي" value={order.deliveryAddress.details.zipCode} mono />}
|
||||
</>
|
||||
)}
|
||||
{!order.deliveryAddress?.details && order.deliveryAddressId && (
|
||||
<>
|
||||
<SectionHeading title="عنوان التسليم" />
|
||||
<DetailRow label="معرّف العنوان" value={order.deliveryAddressId} mono />
|
||||
</>
|
||||
)}
|
||||
|
||||
{order.pickupAddress?.details && Object.values(order.pickupAddress.details).some(Boolean) && (
|
||||
<>
|
||||
<SectionHeading title="عنوان الاستلام" />
|
||||
{order.pickupAddress.details.city && <DetailRow label="المدينة" value={order.pickupAddress.details.city} />}
|
||||
{order.pickupAddress.details.district && <DetailRow label="الحي" value={order.pickupAddress.details.district} />}
|
||||
{order.pickupAddress.details.street && <DetailRow label="الشارع" value={order.pickupAddress.details.street} />}
|
||||
{order.pickupAddress.details.buildingNo && <DetailRow label="رقم المبنى" value={order.pickupAddress.details.buildingNo} mono />}
|
||||
{order.pickupAddress.details.unitNo && <DetailRow label="رقم الوحدة" value={order.pickupAddress.details.unitNo} mono />}
|
||||
{order.pickupAddress.details.zipCode && <DetailRow label="الرمز البريدي" value={order.pickupAddress.details.zipCode} mono />}
|
||||
</>
|
||||
)}
|
||||
{!order.pickupAddress?.details && order.pickupAddressId && (
|
||||
<>
|
||||
<SectionHeading title="عنوان الاستلام" />
|
||||
<DetailRow label="معرّف العنوان" value={order.pickupAddressId} mono />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionHeading title="معلومات النظام" /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
|
||||
|
||||
<SectionHeading title="تحديث الحالة" />
|
||||
{statusError && (
|
||||
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||
<select
|
||||
value={statusDraft}
|
||||
onChange={(e) => setStatusDraft(e.target.value as OrderStatus)}
|
||||
dir="rtl"
|
||||
style={{
|
||||
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)",
|
||||
fontFamily: "var(--font-sans)", cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={statusReason}
|
||||
onChange={(e) => setStatusReason(e.target.value)}
|
||||
placeholder="سبب التغيير (اختياري)"
|
||||
dir="rtl"
|
||||
style={{
|
||||
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)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStatusUpdate}
|
||||
disabled={updatingStatus || !statusDraft || statusDraft === order.currentStatus}
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: updatingStatus ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||
cursor: (updatingStatus || !statusDraft || statusDraft === order.currentStatus) ? "not-allowed" : "pointer",
|
||||
opacity: (!statusDraft || statusDraft === order.currentStatus) ? 0.6 : 1,
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{updatingStatus && <Spinner size="sm" className="text-white" />}
|
||||
{updatingStatus ? "جارٍ التحديث…" : "تحديث الحالة"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{order.statusHistory && order.statusHistory.length > 0 && (
|
||||
<>
|
||||
<SectionHeading title="سجل الحالات" />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{order.statusHistory.slice(0, 5).map((h) => {
|
||||
const s = ORDER_STATUS_MAP[h.status] ?? ORDER_STATUS_MAP.Created;
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{order && (
|
||||
<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(order)}
|
||||
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(order)}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { ORDER_STATUS_MAP, PAY_STATUS_MAP, PAY_METHOD_LABEL };
|
||||
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>
|
||||
);
|
||||
}
|
||||
167
src/Components/Trip/archive/ArchivedTripList.tsx
Normal file
167
src/Components/Trip/archive/ArchivedTripList.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner, Alert, EmptyState } from "@/src/Components/UI";
|
||||
import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
|
||||
interface ArchivedTripListProps {
|
||||
/** Called when the user clicks a row to view trip details */
|
||||
onView?: (trip: Trip) => void;
|
||||
}
|
||||
|
||||
// ── Status badge — reuses the existing trip status color map ────────────────
|
||||
function TripStatusBadge({ status }: { status: Trip["status"] }) {
|
||||
const s = TRIP_STATUS_MAP[status];
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-bold"
|
||||
style={{ color: s.color, background: s.bg, border: `1px solid ${s.border}` }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Date formatting helper ───────────────────────────────────────────────────
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* ArchivedTripList
|
||||
* Displays a paginated, searchable list of archived trips.
|
||||
* Fetches data via useArchivedTrips (GET /trip/archived), handles loading,
|
||||
* empty, and error states, and renders each trip with its key details.
|
||||
*
|
||||
* @example
|
||||
* <ArchivedTripList onView={(trip) => setViewTripId(trip.id)} />
|
||||
*/
|
||||
export function ArchivedTripList({ onView }: ArchivedTripListProps) {
|
||||
const {
|
||||
trips, loading, total, pages, page, search, error,
|
||||
setPage, handleSearch, clearError,
|
||||
} = useArchivedTrips();
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="flex flex-col gap-4">
|
||||
{/* ── Header: count + search ── */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-base font-bold text-[var(--color-text-primary)]">
|
||||
الرحلات المؤرشفة
|
||||
<span className="mr-2 text-[13px] font-medium text-[var(--color-text-muted)]">
|
||||
({total})
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="relative w-full sm:w-72">
|
||||
<svg
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-text-hint)]"
|
||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث برقم الرحلة أو العنوان..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="h-10 w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] pr-9 pl-3 text-[13px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── API error feedback ── */}
|
||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||
|
||||
{/* ── Card ── */}
|
||||
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
|
||||
{/* column headers */}
|
||||
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-muted)] px-6 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
<span>الرحلة</span>
|
||||
<span>الحالة</span>
|
||||
<span className="text-center">البدء</span>
|
||||
<span className="text-center">الانتهاء</span>
|
||||
<span className="text-center">النقد المحصّل</span>
|
||||
</div>
|
||||
|
||||
{/* ── Loading state ── */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-3 py-16 text-[var(--color-text-muted)]">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-[13px]">جارٍ التحميل…</span>
|
||||
</div>
|
||||
) : trips.length === 0 ? (
|
||||
/* ── Empty state ── */
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title="لا توجد رحلات مؤرشفة"
|
||||
description={search ? `لا توجد نتائج لـ "${search}"` : "لم يتم أرشفة أي رحلات بعد."}
|
||||
/>
|
||||
) : (
|
||||
/* ── Data rows ── */
|
||||
<ul className="m-0 list-none p-0">
|
||||
{trips.map((trip, i) => (
|
||||
<li
|
||||
key={trip.id}
|
||||
onClick={() => onView?.(trip)}
|
||||
className={`grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] items-center gap-2 border-b border-[var(--color-border)] px-6 py-3.5 text-[13px] transition-colors hover:bg-[var(--color-surface-muted)] ${
|
||||
onView ? "cursor-pointer" : ""
|
||||
} ${i % 2 !== 0 ? "bg-[var(--color-surface-muted)]/40" : ""}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="m-0 truncate font-semibold text-[var(--color-text-primary)]">{trip.title}</p>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-[var(--color-brand-600)]">{trip.tripNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<TripStatusBadge status={trip.status} />
|
||||
</div>
|
||||
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
{fmtDate(trip.startTime)}
|
||||
</span>
|
||||
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
{fmtDate(trip.endTime)}
|
||||
</span>
|
||||
<span className="text-center font-mono text-[12px] font-semibold text-[var(--color-text-primary)]">
|
||||
{trip.totalCashCollected != null
|
||||
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
|
||||
: "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3.5">
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||
صفحة <strong className="text-[var(--color-text-primary)]">{page}</strong> من{" "}
|
||||
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
السابق
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === pages}
|
||||
onClick={() => setPage(Math.min(pages, page + 1))}
|
||||
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
التالي
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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" }}
|
||||
>
|
||||
{/* icon delete */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
74
src/Components/role/DeleteRoleModal.tsx
Normal file
74
src/Components/role/DeleteRoleModal.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Role } from "@/src/types/role";
|
||||
|
||||
interface DeleteRoleModalProps {
|
||||
role: Role;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) {
|
||||
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-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: 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",
|
||||
}}>
|
||||
{/* 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="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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user