finsh user,car,driver pages also add validator layer to app build trip page

This commit is contained in:
m7amedez5511
2026-06-18 16:51:31 +03:00
parent a23d21f222
commit c1b77f89cd
45 changed files with 4128 additions and 1690 deletions

View File

@@ -0,0 +1,3 @@
export default function AuditPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Audit log module is ready for compliance and activity review.</section>;
}

View File

@@ -0,0 +1,3 @@
export default function BranchesPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Branches module is ready for the location and hub management table.</section>;
}

405
app/dashboard/cars/page.tsx Normal file
View File

@@ -0,0 +1,405 @@
"use client";
// app/(dashboard)/cars/page.tsx
import { useCallback, useState } from "react";
import { Spinner, Alert } from "@/Components/UI";
import { CarFormModal } from "@/Components/car/CarFormModal";
import { CarDetailPanel } from "@/Components/car/CarDetailPanel";
import { CarDeleteModal } from "@/Components/car/CarDeleteModal";
import { useCars, useCarMutations, useToast } from "@/hooks/useCars";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/types/car";
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/types/car";
// ── Toast ─────────────────────────────────────────────────────────────────────
function CarToast({ notification }: { notification: ToastMsg | null }) {
if (!notification) return null;
const ok = notification.type === "success";
return (
<div role="status" aria-live="polite" style={{
position: "fixed", bottom: 24, left: "50%",
transform: "translateX(-50%)",
zIndex: 9999, pointerEvents: "none",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: ok ? "#065F46" : "#7F1D1D",
color: "#FFF", fontSize: 13, fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,.25)",
maxWidth: "90vw", whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}>
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
<span>{notification.message}</span>
</div>
</div>
);
}
// ── CarCard ───────────────────────────────────────────────────────────────────
function CarCard({ car, onClick }: { car: Car; onClick: () => void }) {
const status = STATUS_MAP[car.currentStatus];
const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null;
const regWarn = isExpiringSoon(car.registrationExpiryDate);
return (
<article
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onClick(); }}
aria-label={`${car.manufacturer} ${car.model}${car.plateLetters} ${car.plateNumber}`}
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
cursor: "pointer",
transition: "box-shadow 200ms, transform 200ms",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.boxShadow = "0 8px 24px rgba(37,99,235,.12)";
(e.currentTarget as HTMLElement).style.transform = "translateY(-2px)";
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.boxShadow = "var(--shadow-card)";
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
}}
>
{/* Colour accent bar by status */}
<div style={{
height: 4,
background: status.dot,
borderRadius: "var(--radius-xl) var(--radius-xl) 0 0",
}} />
<div style={{ padding: "1.25rem" }}>
{/* Manufacturer + model */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
<div>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
{car.manufacturer} {car.model}
</p>
<p style={{ fontSize: 12, color: "var(--color-text-muted)", marginTop: 2 }}>
{car.year}{car.color ? ` · ${car.color}` : ""}
</p>
</div>
{/* Status badge */}
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
border: `1px solid ${status.border}`,
background: status.bg,
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: status.color,
whiteSpace: "nowrap", flexShrink: 0,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} />
{status.label}
</span>
</div>
{/* Plate number */}
<div style={{
marginTop: "0.875rem",
background: "var(--color-surface-muted)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)",
padding: "0.5rem 0.75rem",
display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)", fontWeight: 600 }}>رقم اللوحة</span>
<span style={{
fontFamily: "var(--font-mono)",
fontSize: 14, fontWeight: 700, letterSpacing: "0.1em",
color: "var(--color-brand-700)",
}}>
{car.plateLetters} {car.plateNumber}
</span>
</div>
{/* Key attributes grid */}
<div style={{ marginTop: "0.875rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
<div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>الفرع</span>
<span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}>
{car.branch?.name ?? "—"}
</span>
</div>
<div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التأمين</span>
<span style={{ color: ins?.color ?? "var(--color-text-muted)", marginTop: 2, display: "block", fontWeight: 600 }}>
{ins?.label ?? "—"}
</span>
</div>
<div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>انتهاء الاستمارة</span>
<span style={{ color: regWarn ? "#D97706" : "var(--color-text-secondary)", marginTop: 2, display: "block", fontWeight: regWarn ? 600 : 400 }}>
{regWarn && "⚠ "}{fmtDateShort(car.registrationExpiryDate)}
</span>
</div>
<div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>الطاقة</span>
<span style={{ color: "var(--color-text-secondary)", marginTop: 2, display: "block" }}>
{car.capacity != null ? car.capacity : "—"}
</span>
</div>
</div>
{/* Footer CTA hint */}
<p style={{ marginTop: "0.875rem", fontSize: 11, color: "var(--color-brand-600)", fontWeight: 600, textAlign: "left" }}>
اضغط لعرض التفاصيل
</p>
</div>
</article>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function CarsPage() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
// Modal state
const [detailId, setDetailId] = useState<string | null>(null);
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
const { toast, notify } = useToast();
const { cars, loading, error, total, pages, loadCars, removeCar, setError } =
useCars(page, search);
// Need a stable ref to the current edit target for the mutation hook
const getEditTarget = useCallback(() =>
formTarget instanceof Object && formTarget !== null ? formTarget as Car : null,
[formTarget]);
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMutations({
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadCars(); },
onError: (msg) => notify({ type: "error", message: msg }),
onDeleted: (id) => { removeCar(id); setDeleteTarget(null); },
getEditTarget,
});
const handleDelete = async () => {
if (!deleteTarget) return;
await handleDeleteConfirm(deleteTarget);
};
// ── Render ──────────────────────────────────────────────────────────────────
return (
<>
<CarToast notification={toast} />
{detailId && (
<CarDetailPanel
carId={detailId}
onClose={() => setDetailId(null)}
onEdit={(car) => { setDetailId(null); setFormTarget(car); }}
onDelete={(car) => { setDetailId(null); setDeleteTarget(car); }}
/>
)}
{formTarget !== false && (
<CarFormModal
editCar={formTarget}
branches={[]}
onClose={() => setFormTarget(false)}
onSubmit={(payload: CreateCarPayload | UpdateCarPayload, isNew: boolean) =>
handleFormSubmit(payload, isNew).then((ok) => { if (ok) setFormTarget(false); return ok; })
}
/>
)}
{deleteTarget && (
<CarDeleteModal
car={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl">
{/* ── Header ── */}
<header style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الأسطول
</p>
<div style={{ marginTop: "0.75rem", display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-end", justifyContent: "space-between" }}>
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
المركبات
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> مركبة في الأسطول
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
{/* Search */}
<div style={{ position: "relative", width: 256 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالماركة أو اللوحة..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
dir="rtl"
style={{
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
}}
/>
</div>
{/* Add button */}
<button
type="button"
onClick={() => setFormTarget(null)}
style={{
height: 40, padding: "0 1.125rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 7,
fontFamily: "var(--font-sans)",
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
whiteSpace: "nowrap",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة مركبة
</button>
</div>
</div>
</header>
{/* Error alert */}
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
{/* ── Loading ── */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "5rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="md" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ تحميل المركبات</span>
</div>
) : cars.length === 0 ? (
<div style={{
borderRadius: "var(--radius-xl)",
border: "2px dashed var(--color-border)",
background: "var(--color-surface)",
padding: "5rem 2rem",
textAlign: "center",
}}>
<div style={{ fontSize: 52, marginBottom: 16 }}>🚗</div>
<p style={{ fontSize: 16, fontWeight: 600, color: "var(--color-text-primary)" }}>
{search ? `لا توجد مركبات تطابق "${search}"` : "لا توجد مركبات بعد"}
</p>
<p style={{ fontSize: 13, color: "var(--color-text-muted)", marginTop: 6 }}>
اضغط على &quot;إضافة مركبة&quot; لإضافة أول مركبة في الأسطول.
</p>
{!search && (
<button
type="button"
onClick={() => setFormTarget(null)}
style={{
marginTop: 16, height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إضافة مركبة
</button>
)}
</div>
) : (
/* ── Card grid ── */
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: "1rem",
}}>
{cars.map((car) => (
<CarCard
key={car.id}
car={car}
onClick={() => setDetailId(car.id)}
/>
))}
</div>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "0.875rem 1.5rem",
boxShadow: "var(--shadow-card)",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
{" · "}
<span style={{ color: "var(--color-text-hint)" }}>{total} مركبة</span>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "← السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي →", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
].map((btn) => (
<button
key={btn.label}
onClick={btn.action}
disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
fontWeight: 600,
}}
>
{btn.label}
</button>
))}
</div>
</div>
)}
</section>
</>
);
}

View File

@@ -0,0 +1,538 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Alert } from "@/Components/UI";
import { Toast } from "@/Components/Client/Toast";
import { useClientAddresses } from "@/hooks/useClientAddresses";
import { clientService } from "@/services/client.service";
import { getStoredToken } from "@/lib/auth";
import type {
Client,
ClientAddress,
ClientAddressFormData,
} from "@/types/client";
import { AddressFormModal } from "@/Components/Client/Addressformmodal";
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal";
// ── Helpers ────────────────────────────────────────────────────────────────
/** Returns a context-appropriate emoji for each common address label */
function labelIcon(label: string): string {
const map: Record<string, string> = {
"فوترة": "💳",
"شحن": "📦",
"المقر الرئيسي": "🏢",
"فرع": "🏬",
"مستودع": "🏭",
billing: "💳",
shipping: "📦",
"head office": "🏢",
branch: "🏬",
warehouse: "🏭",
};
return map[label.toLowerCase()] ?? "📍";
}
// ── Address card sub-component ─────────────────────────────────────────────
// Mirrors the AddressCard in the old page — kept co-located because it's only used here.
interface AddressCardProps {
address: ClientAddress;
onEdit: () => void;
onDelete: () => void;
onMakePrimary: () => void;
}
function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardProps) {
return (
<div
style={{
position: "relative",
display: "flex",
flexDirection: "column",
gap: "0.75rem",
borderRadius: "var(--radius-xl)",
border: address.isPrimary
? "1px solid #93C5FD"
: "1px solid var(--color-border)",
padding: "1.25rem",
background: "var(--color-surface)",
boxShadow: address.isPrimary
? "0 0 0 2px #BFDBFE, var(--shadow-card)"
: "var(--shadow-card)",
}}
>
{/* Label row */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 20 }} aria-hidden="true">
{labelIcon(address.label)}
</span>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--color-text-primary)" }}>
{address.label}
</span>
</div>
{address.isPrimary && (
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "#1D4ED8",
background: "#EFF6FF",
border: "1px solid #BFDBFE",
borderRadius: "var(--radius-full)",
padding: "0.15rem 0.5rem",
}}
>
رئيسي
</span>
)}
</div>
{/* Address lines */}
<address
dir="rtl"
style={{
fontStyle: "normal",
fontSize: 13,
color: "var(--color-text-secondary)",
lineHeight: 1.7,
}}
>
<p style={{ margin: 0 }}>{address.street}</p>
<p style={{ margin: 0 }}>
{address.city}، {address.state} {address.postalCode}
</p>
<p style={{ margin: 0 }}>{address.country}</p>
</address>
{/* Actions */}
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 8,
paddingTop: 8,
borderTop: "1px solid var(--color-border)",
}}
>
<ActionBtn onClick={onEdit} color="#1D4ED8" bg="#EFF6FF" border="#BFDBFE">
تعديل
</ActionBtn>
{!address.isPrimary && (
<ActionBtn
onClick={onMakePrimary}
color="#065F46"
bg="#DCFCE7"
border="#BBF7D0"
>
تعيين كرئيسي
</ActionBtn>
)}
<ActionBtn
onClick={onDelete}
color="#DC2626"
bg="#FEF2F2"
border="#FECACA"
style={{ marginRight: "auto" }}
>
حذف
</ActionBtn>
</div>
</div>
);
}
function ActionBtn({
onClick,
color,
bg,
border,
style,
children,
}: {
onClick: () => void;
color: string;
bg: string;
border: string;
style?: React.CSSProperties;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
height: 30,
padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${border}`,
background: bg,
fontSize: 12,
fontWeight: 600,
color,
cursor: "pointer",
fontFamily: "var(--font-sans)",
...style,
}}
>
{children}
</button>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function ClientAddressesPage() {
const params = useParams();
const router = useRouter();
const clientId = params?.clientId as string;
console.log('clientId:', params.clientId); // شوف إيه اللي بيجي
// ── Parent client meta ───────────────────────────────────────────────────
const [client, setClient] = useState<Client | null>(null);
const [clientLoading, setClientLoading] = useState(true);
useEffect(() => {
if (!clientId) return;
setClientLoading(true);
clientService
.getById(clientId, getStoredToken())
.then((res) => setClient(res.data))
.catch(() => router.replace("/clients"))
.finally(() => setClientLoading(false));
}, [clientId, router]);
// ── Address CRUD hook ────────────────────────────────────────────────────
const {
addresses,
loading: addrLoading,
error,
notification,
clearError,
createAddress,
updateAddress,
deleteAddress,
makePrimary,
} = useClientAddresses(clientId ?? "");
// ── Modal state (mirrors UsersPage pattern) ──────────────────────────────
// false = closed | null = create mode | ClientAddress = edit mode
const [formTarget, setFormTarget] = useState<ClientAddress | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
const [deleting, setDeleting] = useState(false);
// ── Handlers ─────────────────────────────────────────────────────────────
const handleFormSubmit = async (
data: ClientAddressFormData,
isNew: boolean
): Promise<boolean> => {
if (isNew) return createAddress(data);
return updateAddress((formTarget as ClientAddress).id, data);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return;
setDeleting(true);
const ok = await deleteAddress(deleteTarget.id);
setDeleting(false);
if (ok) setDeleteTarget(null);
};
// ── Guard: wait for router + client ─────────────────────────────────────
if (!clientId || clientLoading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
border: "3px solid var(--color-border)",
borderTopColor: "var(--color-brand-600)",
animation: "spin 0.7s linear infinite",
}}
/>
</div>
);
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
{/* Global success / error toast */}
<Toast notification={notification} />
{/* Create / Edit address modal */}
{formTarget !== false && (
<AddressFormModal
editAddress={formTarget}
onClose={() => setFormTarget(false)}
onSubmit={handleFormSubmit}
/>
)}
{/* Delete confirmation — reuses the same DeleteConfirmModal shape */}
{deleteTarget && (
<DeleteConfirmModal
// Cast address as Client for the modal (same shape: id + name)
client={
{
...deleteTarget,
name: deleteTarget.label,
email: "",
phone: "",
createdAt: deleteTarget.createdAt,
updatedAt: deleteTarget.updatedAt,
} as Client
}
deleting={deleting}
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
onConfirm={handleDeleteConfirm}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */}
<header
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}
>
{/* Back link */}
<button
type="button"
onClick={() => router.push("/clients")}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
marginBottom: 12,
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
جميع العملاء
</button>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
}}
>
إدارة العناوين
</p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{client ? `${client.name} — العناوين` : "العناوين"}
</h1>
<p
style={{
marginTop: "0.25rem",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{addresses.length}
</strong>{" "}
عنوان
</p>
</div>
{/* Add address button */}
<button
type="button"
onClick={() => setFormTarget(null)}
style={{
height: 40,
padding: "0 1.125rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--font-sans)",
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
whiteSpace: "nowrap",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة عنوان
</button>
</div>
{/* Client info strip */}
{client && (
<div
dir="rtl"
style={{
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
gap: "1rem",
fontSize: 13,
color: "var(--color-text-secondary)",
paddingTop: "1rem",
borderTop: "1px solid var(--color-border)",
}}
>
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
{client.name}
</span>
<a
href={`mailto:${client.email}`}
style={{ color: "#2563EB", textDecoration: "none" }}
>
{client.email}
</a>
<span>{client.phone}</span>
{client.taxId && (
<code
style={{
fontSize: 11,
background: "var(--color-surface-muted)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-sm)",
padding: "0.1rem 0.4rem",
}}
>
{client.taxId}
</code>
)}
</div>
)}
</header>
{/* General load error */}
{error && <Alert type="error" message={error} onClose={clearError} />}
{/* Address grid */}
{addrLoading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4rem 0",
color: "var(--color-text-muted)",
fontSize: 13,
}}
>
جارٍ التحميل
</div>
) : addresses.length === 0 ? (
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "4rem 1rem",
textAlign: "center",
boxShadow: "var(--shadow-card)",
}}
>
<p style={{ fontSize: 32, margin: 0 }}>📍</p>
<p
style={{
marginTop: 12,
fontSize: 15,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
لا توجد عناوين بعد
</p>
<p
style={{
marginTop: 4,
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات والفواتير.
</p>
<button
type="button"
onClick={() => setFormTarget(null)}
style={{
marginTop: 16,
fontSize: 13,
fontWeight: 600,
color: "var(--color-brand-600)",
background: "none",
border: "none",
cursor: "pointer",
textDecoration: "underline",
}}
>
أضف أول عنوان
</button>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: "1rem",
}}
>
{addresses.map((addr) => (
<AddressCard
key={addr.id}
address={addr}
onEdit={() => setFormTarget(addr)}
onDelete={() => setDeleteTarget(addr)}
onMakePrimary={() => makePrimary(addr.id)}
/>
))}
</div>
)}
</section>
</>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Alert } from "@/Components/UI";
import { Toast } from "@/Components/Client/Toast";
import { useClients } from "@/hooks/useClients";
import type { Client, ClientFormData } from "@/types/client";
import { ClientFormModal } from "@/Components/Client/Clientformmodal";
import { ClientTable } from "@/Components/Client/Clienttable";
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal";
export default function ClientsPage() {
const router = useRouter();
// ── Modal state ──────────────────────────────────────────────────────────
// false = closed | null = create mode | Client = edit mode
const [formTarget, setFormTarget] = useState<Client | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<Client | null>(null);
const [deleting, setDeleting] = useState(false);
// ── Data hook ────────────────────────────────────────────────────────────
const {
clients, loading, total, pages, error,
page, search,
setPage, handleSearch, clearError,
createClient, updateClient, deleteClient,
notification,
} = useClients();
// ── Create / Update handler ──────────────────────────────────────────────
const handleFormSubmit = async (
data: ClientFormData,
isNew: boolean
): Promise<boolean> => {
if (isNew) return createClient(data);
return updateClient((formTarget as Client).id, data);
};
// ── Delete handler ───────────────────────────────────────────────────────
const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return;
setDeleting(true);
const ok = await deleteClient(deleteTarget.id);
setDeleting(false);
if (ok) setDeleteTarget(null);
};
// ── Navigate to address management ──────────────────────────────────────
const handleManageAddresses = (client: Client) => {
router.push(`/dashboard/clients/${client.id}/addresses`);
};
// ── Render ───────────────────────────────────────────────────────────────
return (
<>
{/* Global success / error toast */}
<Toast notification={notification} />
{/* Create / Edit modal */}
{formTarget !== false && (
<ClientFormModal
editClient={formTarget}
onClose={() => setFormTarget(false)}
onSubmit={handleFormSubmit}
/>
)}
{/* Delete confirmation dialog */}
{deleteTarget && (
<DeleteConfirmModal
client={deleteTarget}
deleting={deleting}
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
onConfirm={handleDeleteConfirm}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */}
<header
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
}}
>
إدارة العملاء
</p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
العملاء
</h1>
<p
style={{
marginTop: "0.25rem",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{total}
</strong>{" "}
عميل
</p>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "center",
}}
>
{/* Search input */}
<div style={{ position: "relative", width: 256 }}>
<svg
style={{
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
width: 16,
height: 16,
color: "var(--color-text-hint)",
pointerEvents: "none",
}}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف أو البريد…"
value={search}
onChange={(e) => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%",
height: 40,
paddingRight: 36,
paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{/* Add client button */}
<button
type="button"
onClick={() => setFormTarget(null)}
style={{
height: 40,
padding: "0 1.125rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--font-sans)",
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
whiteSpace: "nowrap",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة عميل
</button>
</div>
</div>
</header>
{/* General load error */}
{error && <Alert type="error" message={error} onClose={clearError} />}
{/* Clients table */}
<ClientTable
clients={clients}
loading={loading}
search={search}
page={page}
pages={pages}
onEdit={(client) => setFormTarget(client)}
onDelete={(client) => setDeleteTarget(client)}
onAddFirst={() => setFormTarget(null)}
onPageChange={setPage}
onManageAddresses={handleManageAddresses}
/>
</section>
</>
);
}

View File

@@ -0,0 +1,624 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Spinner } from "@/Components/UI";
import { DriverFormModal } from "@/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal";
import { DriverReportPanel } from "@/Components/Driver_Report/driverReport";
import { driverService } from "@/services";
import { getStoredToken } from "@/lib/auth";
import type {
Driver,
CreateDriverPayload,
UpdateDriverPayload,
} from "@/types/driver";
import {
DRIVER_STATUS_MAP,
DRIVER_CARD_TYPE_MAP,
NATIONAL_ID_TYPE_MAP,
} from "@/types/driver";
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function isExpiringSoon(iso?: string | null): boolean {
if (!iso) return false;
return (new Date(iso).getTime() - Date.now()) <= 90 * 86_400_000;
}
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
}}
>
<div
style={{
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: 0,
}}
>
{title}
</p>
</div>
<div style={{ padding: "1rem 1.5rem" }}>{children}</div>
</div>
);
}
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 PhotoCard({ url, label }: { url?: string | null; label: string }) {
const [imgError, setImgError] = useState(false);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-muted)" }}>
{label}
</span>
<div
style={{
width: "100%",
aspectRatio: "4/3",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{url && !imgError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={label}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setImgError(true)}
/>
) : (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)}
</div>
{url && !imgError && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 11, color: "#2563EB", textDecoration: "underline", textAlign: "center" }}
>
عرض الصورة
</a>
)}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DriverDetailPage() {
const params = useParams();
const router = useRouter();
const driverId = params?.driverId as string;
const [driver, setDriver] = useState<Driver | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState(false);
// ── Modal state ───────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
// ── Load driver ───────────────────────────────────────────────────────────
const loadDriver = useCallback(async () => {
if (!driverId) return;
setLoading(true);
setError(null);
try {
const token = getStoredToken();
const res = await driverService.getById(driverId, token);
setDriver((res as unknown as { data: Driver }).data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.");
} finally {
setLoading(false);
}
}, [driverId]);
useEffect(() => {
loadDriver();
}, [loadDriver]);
// ── Edit submit ───────────────────────────────────────────────────────────
const handleEditSubmit = useCallback(
async (
payload: CreateDriverPayload | UpdateDriverPayload,
_isNew: boolean,
): Promise<boolean> => {
if (!driver) return false;
try {
const token = getStoredToken();
await driverService.update(driver.id, payload as UpdateDriverPayload, token);
await loadDriver();
return true;
} catch {
return false;
}
},
[driver, loadDriver],
);
// ── Delete confirm ────────────────────────────────────────────────────────
const handleConfirmDelete = useCallback(async () => {
if (!driver) return;
setDeleting(true);
try {
const token = getStoredToken();
await driverService.delete(driver.id, token);
router.push("/dashboard/drivers");
} catch {
setDeleting(false);
}
}, [driver, router]);
// ── Status config ─────────────────────────────────────────────────────────
const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null;
// ── Render: Loading ───────────────────────────────────────────────────────
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "6rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ التحميل</span>
</div>
);
}
// ── Render: Error ─────────────────────────────────────────────────────────
if (error || !driver) {
return (
<div
style={{
maxWidth: 480,
margin: "4rem auto",
borderRadius: "var(--radius-xl)",
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "1.5rem",
textAlign: "center",
}}
>
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
{error ?? "السائق غير موجود"}
</p>
<button
type="button"
onClick={() => router.back()}
style={{
marginTop: "1rem",
height: 38,
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: "pointer",
fontFamily: "var(--font-sans)",
}}
>
رجوع
</button>
</div>
);
}
// ── Render: Driver detail ─────────────────────────────────────────────────
return (
<>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */}
<header
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}
>
{/* Back link */}
<button
type="button"
onClick={() => router.back()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
marginBottom: "1rem",
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
العودة إلى قائمة السائقين
</button>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
{/* Avatar + name */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div
style={{
width: 72,
height: 72,
borderRadius: "50%",
overflow: "hidden",
flexShrink: 0,
border: "2px solid var(--color-brand-200)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{driver.photoUrl && !avatarError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={driver.photoUrl}
alt={driver.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setAvatarError(true)}
/>
) : (
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>
{driver.name.charAt(0)}
</span>
)}
</div>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
ملف السائق
</p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{driver.name}
</h1>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
{driver.userName && (
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
@{driver.userName}
</span>
)}
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
{driver.phone}
</span>
{statusConfig && (
<span
style={{
borderRadius: "var(--radius-full)",
border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 700,
color: statusConfig.color,
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
{statusConfig.label}
</span>
)}
</div>
</div>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button
type="button"
onClick={() => setDeleteOpen(true)}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "1px solid #FECACA",
background: "#FEF2F2",
fontSize: 13,
fontWeight: 700,
color: "#DC2626",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
حذف السائق
</button>
<button
type="button"
onClick={() => setEditOpen(true)}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<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>
</div>
</div>
</header>
{/* ── Content grid ── */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.5rem",
}}
>
{/* Personal Info */}
<SectionCard title="البيانات الشخصية">
<DetailRow label="الاسم الكامل" value={driver.name} />
<DetailRow label="رقم الجوال" value={driver.phone} mono />
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
<DetailRow label="العنوان" value={driver.address ?? "—"} />
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
</SectionCard>
{/* ID & GOSI */}
<SectionCard title="الهوية والتأمينات">
<DetailRow
label="نوع الهوية"
value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"}
/>
<DetailRow label="رقم الهوية" value={driver.nationalId ?? "—"} mono />
<DetailRow
label="انتهاء الهوية"
value={fmtDate(driver.nationalIdExpiry)}
warn={isExpiringSoon(driver.nationalIdExpiry)}
/>
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
</SectionCard>
{/* License */}
<SectionCard title="بيانات رخصة القيادة">
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono />
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
<DetailRow
label="انتهاء الرخصة"
value={fmtDate(driver.licenseExpiry)}
warn={isExpiringSoon(driver.licenseExpiry)}
/>
</SectionCard>
{/* Driver Card */}
<SectionCard title="بطاقة السائق">
<DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono />
<DetailRow
label="نوع البطاقة"
value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"}
/>
<DetailRow
label="انتهاء البطاقة"
value={fmtDate(driver.driverCardExpiry)}
warn={isExpiringSoon(driver.driverCardExpiry)}
/>
</SectionCard>
{/* System Info */}
<SectionCard title="معلومات النظام">
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono />
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
</SectionCard>
{/* Status History */}
{driver.statusHistory && driver.statusHistory.length > 0 && (
<SectionCard title="سجل الحالات">
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{driver.statusHistory.map((h) => {
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
return (
<div
key={h.id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderRadius: "var(--radius-md)",
border: `1px solid ${s.border}`,
background: s.bg,
padding: "0.5rem 0.875rem",
}}
>
<div>
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>
{s.label}
</span>
{h.reason && (
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
{h.reason}
</span>
)}
</div>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{fmtDate(h.createdAt)}
</span>
</div>
);
})}
</div>
</SectionCard>
)}
</div>
{/* ── Photos section (full width) ── */}
<SectionCard title="الصور والمستندات">
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1.25rem" }}>
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة بطاقة السائق" />
</div>
</SectionCard>
{/* ── Report section (full width) ── */}
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
}}
>
<DriverReportPanel driverId={driver.id} />
</div>
</section>
{/* ── Edit modal ── */}
{editOpen && (
<DriverFormModal
editDriver={driver}
branches={[]}
onClose={() => setEditOpen(false)}
onSubmit={handleEditSubmit}
/>
)}
{/* ── Delete modal ── */}
{deleteOpen && (
<DriverDeleteModal
driver={driver}
deleting={deleting}
onCancel={() => setDeleteOpen(false)}
onConfirm={handleConfirmDelete}
/>
)}
</>
);
}

View File

@@ -0,0 +1,378 @@
"use client";
import { useState, useCallback } from "react";
import { Alert, Spinner } from "@/Components/UI";
import { DriverFormModal } from "@/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal";
import { driverService } from "@/services";
import { getStoredToken } from "@/lib/auth";
import { useDrivers } from "@/hooks/useDriver";
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/types/driver";
import { DriverDetailPanel } from "@/Components/Driver/DriverDetailPanel";
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
});
}
function expirySoon(iso?: string | null): boolean {
if (!iso) return false;
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
}
// ── Styles ────────────────────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Page Component ────────────────────────────────────────────────────────────
export default function DriversPage() {
const {
drivers, loading, error, total, pages, page,
search, setPage, handleSearch, clearError,
deleteDriver, notification, reload,
} = useDrivers();
// ── Panel / modal state ───────────────────────────────────────────────────
const [selectedDriverId, setSelectedDriverId] = useState<string | null>(null);
const [formDriver, setFormDriver] = useState<Driver | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null);
const [deleting, setDeleting] = useState(false);
// ── Handlers ──────────────────────────────────────────────────────────────
const handleEdit = useCallback((driver: Driver) => {
setSelectedDriverId(null);
setFormDriver(driver);
}, []);
const handleDelete = useCallback((driver: Driver) => {
setSelectedDriverId(null);
setDeleteTarget(driver);
}, []);
const handleFormSubmit = useCallback(
async (
payload: CreateDriverPayload | UpdateDriverPayload,
isNew: boolean,
): Promise<boolean> => {
try {
const token = getStoredToken();
if (isNew) {
// Use multipart if there are file fields
const hasFiles =
(payload as CreateDriverPayload & { photo?: File }).photo ||
(payload as CreateDriverPayload & { nationalPhoto?: File }).nationalPhoto ||
(payload as CreateDriverPayload & { driverCardPhoto?: File }).driverCardPhoto;
if (hasFiles) {
await driverService.createWithImages(
payload as Parameters<typeof driverService.createWithImages>[0],
token,
);
} else {
await driverService.create(payload as CreateDriverPayload, token);
}
} else {
const id = (formDriver as Driver).id;
await driverService.update(id, payload as UpdateDriverPayload, token);
}
reload();
return true;
} catch {
return false;
}
},
[formDriver, reload],
);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
setDeleting(true);
const ok = await deleteDriver(deleteTarget.id);
setDeleting(false);
if (ok) setDeleteTarget(null);
}, [deleteTarget, deleteDriver]);
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Header ── */}
<header style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الكوادر
</p>
<div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
السائقون
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "}
سائق مسجل
</p>
</div>
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
{/* Search */}
<div style={{ position: "relative", width: 288 }}>
<svg
style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
}}
/>
</div>
{/* Add button */}
<button
type="button"
onClick={() => setFormDriver("new")}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
flexShrink: 0,
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 5v14M5 12h14" />
</svg>
إضافة سائق
</button>
</div>
</div>
</div>
</header>
{/* ── Notifications ── */}
{notification && (
<Alert
type={notification.type}
message={notification.message}
/>
)}
{error && (
<Alert type="error" message={error} onClose={clearError} />
)}
{/* ── Table ── */}
<div style={cardStyle}>
<div
dir="rtl"
style={{
display: "grid",
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
...thStyle,
}}
>
<span>السائق</span>
<span>الفرع</span>
<span>الجنسية</span>
<span>انتهاء الرخصة</span>
<span>انتهاء الهوية</span>
<span>الحالة</span>
</div>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : drivers.length === 0 ? (
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}>
لا توجد نتائج {search && `لـ "${search}"`}
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{drivers.map((d, i) => {
const statusCfg = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
const licWarn = expirySoon(d.licenseExpiry);
const idWarn = expirySoon((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry);
return (
<li
key={d.id}
onClick={() => setSelectedDriverId(d.id)}
style={{
display: "grid",
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
alignItems: "center", gap: "0.5rem",
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
cursor: "pointer",
transition: "background 0.15s",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")}
onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")}
>
{/* Name + phone */}
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
</div>
{/* Branch */}
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
{/* Nationality */}
<span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span>
{/* License expiry */}
<span style={{ fontSize: 12, fontWeight: licWarn ? 600 : 400, color: licWarn ? "#D97706" : "var(--color-text-secondary)" }}>
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
</span>
{/* National ID expiry */}
<span style={{ fontSize: 12, fontWeight: idWarn ? 600 : 400, color: idWarn ? "#D97706" : "var(--color-text-secondary)" }}>
{idWarn && "⚠ "}{fmtDate((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry)}
</span>
{/* Status badge */}
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
border: `1px solid ${statusCfg.border}`,
background: statusCfg.bg,
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600, color: statusCfg.color,
width: "fit-content",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} />
{statusCfg.label}
</span>
</li>
);
})}
</ul>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div
dir="rtl"
style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
].map(btn => (
<button
key={btn.label}
onClick={btn.action}
disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}
>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section>
{/* ── Detail panel ── */}
{selectedDriverId && (
<DriverDetailPanel
driverId={selectedDriverId}
onClose={() => setSelectedDriverId(null)}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
{/* ── Form modal ── */}
{formDriver !== null && (
<DriverFormModal
editDriver={formDriver === "new" ? null : formDriver}
branches={[]}
onClose={() => setFormDriver(null)}
onSubmit={handleFormSubmit}
/>
)}
{/* ── Delete modal ── */}
{deleteTarget && (
<DriverDeleteModal
driver={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleConfirmDelete}
/>
)}
</>
);
}

View File

@@ -7,7 +7,10 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen" style={{ background: "var(--color-surface-muted)" }}>
<div
className="flex min-h-screen"
style={{ background: "var(--color-surface-muted)" }}
>
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />

View File

@@ -0,0 +1,281 @@
"use client";
import { useEffect, useState } from "react";
import { getStoredToken } from "@/lib/auth";
import { get } from "@/services/api";
import { Spinner, Alert } from "@/Components/UI";
interface Order {
id: string;
shipmentNumber: string;
recipientName: string;
recipientPhone: string;
currentStatus: "Created" | "Assigned" | "InTransit" | "Delivered" | "Returned" | "Cancelled";
totalPrice?: number;
paymentMethod?: string;
paymentStatus?: "Pending" | "Paid" | "Failed" | "Refunded";
client?: { name: string };
trip?: { tripNumber: string };
createdAt: string;
}
interface ApiResponse {
data: {
data: Order[];
pagination: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
const STATUS_MAP: Record<Order["currentStatus"], { label: string; color: string; bg: string; border: string }> = {
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE" },
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE" },
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A" },
Delivered: { label: "تم التسليم", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
Returned: { label: "مُرتجع", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA" },
Cancelled: { label: "ملغي", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0" },
};
const PAY_MAP: Record<string, { label: string; color: string }> = {
Pending: { label: "معلَّق", color: "#D97706" },
Paid: { label: "مدفوع", color: "#16A34A" },
Failed: { label: "فشل", color: "#DC2626" },
Refunded: { label: "مُسترجع", color: "#64748B" },
};
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
export default function OrdersPage() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
useEffect(() => {
const token = getStoredToken();
setLoading(true);
setError(null);
const params = new URLSearchParams({ page: String(page), limit: "10" });
if (search) params.set("search", search);
if (statusFilter) params.set("currentStatus", statusFilter);
get<ApiResponse>(`orders?${params}`, token)
.then((res) => {
const payload = res.data ?? res;
setOrders(payload.data ?? []);
setTotal(payload.meta?.total ?? 0);
setPages(payload.meta?.pages ?? 1);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [page, search, statusFilter]);
return (
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Header */}
<header style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الشحنات
</p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
الطلبات
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> طلب
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{/* Status filter */}
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
dir="rtl"
style={{
height: 40, borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-secondary)",
padding: "0 0.75rem", outline: "none",
fontFamily: "var(--font-sans)",
}}
>
<option value="">كل الحالات</option>
{Object.entries(STATUS_MAP).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
{/* Search */}
<div style={{ position: "relative", width: 256 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" placeholder="رقم الشحنة أو المستلم..." value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} dir="rtl"
style={{
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
}}
/>
</div>
</div>
</div>
</header>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
{/* Table */}
<div style={cardStyle}>
<div dir="rtl" style={{
display: "grid",
gridTemplateColumns: "1.8fr 1.5fr 1.2fr 1fr 1fr 1fr",
...thStyle,
}}>
<span>رقم الشحنة</span>
<span>المستلم</span>
<span>العميل</span>
<span>المبلغ</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>التاريخ</span>
</div>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : orders.length === 0 ? (
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}>
لا توجد نتائج
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{orders.map((o, i) => {
const status = STATUS_MAP[o.currentStatus];
const pay = o.paymentStatus ? PAY_MAP[o.paymentStatus] : null;
return (
<li key={o.id} style={{
display: "grid",
gridTemplateColumns: "1.8fr 1.5fr 1.2fr 1fr 1fr 1fr",
alignItems: "center", gap: "0.5rem",
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontFamily: "var(--font-mono)", fontSize: 12, fontWeight: 700, color: "#2563EB", margin: 0 }}>
{o.shipmentNumber}
</p>
{o.trip && (
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>
رحلة: {o.trip.tripNumber}
</p>
)}
</div>
<div>
<p style={{ color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
{o.recipientPhone}
</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
{o.totalPrice != null ? `${o.totalPrice.toFixed(2)} ر.س` : "—"}
</p>
{pay && (
<p style={{ marginTop: 2, fontSize: 11, fontWeight: 600, color: pay.color }}>
{pay.label}
</p>
)}
</div>
<span style={{
display: "inline-flex", alignItems: "center",
borderRadius: "var(--radius-full)",
border: `1px solid ${status.border}`,
background: status.bg,
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600, color: status.color,
width: "fit-content",
}}>
{status.label}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(o.createdAt).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
})}
</span>
</li>
);
})}
</ul>
)}
{pages > 1 && (
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,3 @@
export default function RolesPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Roles and permissions module is ready for RBAC assignment and matrix views.</section>;
}

View File

@@ -0,0 +1,498 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Spinner } from "@/Components/UI";
import { TripFormModal } from "@/Components/Trip/Tripformmodal";
import { TripDeleteModal } from "@/Components/Trip/Tripdeletemodal";
import { TripReportPanel } from "@/Components/Trip_Report/Tripreportpanel";
import { tripService } from "@/services/trip.service";
import { getStoredToken } from "@/lib/auth";
import type {
Trip,
CreateTripPayload,
UpdateTripPayload,
} from "@/types/trip";
import { TRIP_STATUS_MAP } from "@/types/trip";
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function fmtNumber(n?: number | string | null): string {
if (n == null || n === "") return "—";
return Number(n).toLocaleString("ar-SA");
}
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
}}
>
<div style={{
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<p style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: 0,
}}>
{title}
</p>
</div>
<div style={{ padding: "1rem 1.5rem" }}>{children}</div>
</div>
);
}
function DetailRow({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: 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: "var(--color-text-primary)",
maxWidth: "60%",
textAlign: "left",
wordBreak: "break-word",
}}>
{value}
</span>
</div>
);
}
// ── Stat card (للأعداد) ───────────────────────────────────────────────────────
function StatCard({
label,
value,
color = "var(--color-brand-600)",
bg = "var(--color-brand-50, #EFF6FF)",
}: {
label: string;
value: string;
color?: string;
bg?: string;
}) {
return (
<div style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: bg,
padding: "1rem 1.25rem",
display: "flex",
flexDirection: "column",
gap: 4,
}}>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 22, fontWeight: 700, color, fontFamily: "var(--font-mono)" }}>
{value}
</span>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function TripDetailPage() {
const params = useParams();
const router = useRouter();
const tripId = params?.tripId as string;
const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// ── Modal state ───────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
// ── Load trip ─────────────────────────────────────────────────────────────
const loadTrip = useCallback(async () => {
if (!tripId) return;
setLoading(true);
setError(null);
try {
const token = getStoredToken();
const res = await tripService.getById(tripId, token);
setTrip((res as unknown as { data: Trip }).data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
} finally {
setLoading(false);
}
}, [tripId]);
useEffect(() => { loadTrip(); }, [loadTrip]);
// ── Edit submit ───────────────────────────────────────────────────────────
const handleEditSubmit = useCallback(
async (
payload: CreateTripPayload | UpdateTripPayload,
_isNew: boolean,
): Promise<boolean> => {
if (!trip) return false;
try {
const token = getStoredToken();
await tripService.update(trip.id, payload as UpdateTripPayload, token);
await loadTrip();
return true;
} catch {
return false;
}
},
[trip, loadTrip],
);
// ── Delete ────────────────────────────────────────────────────────────────
const handleConfirmDelete = useCallback(async () => {
if (!trip) return;
setDeleting(true);
try {
const token = getStoredToken();
await tripService.delete(trip.id, token);
router.back();
} catch {
setDeleting(false);
setDeleteOpen(false);
}
}, [trip, router]);
const statusConfig = trip ? TRIP_STATUS_MAP[trip.status] : null;
// ── Loading ───────────────────────────────────────────────────────────────
if (loading) {
return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<Spinner size="lg" />
</div>
);
}
// ── Error ─────────────────────────────────────────────────────────────────
if (error || !trip) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<p style={{ fontSize: 14, color: "var(--color-danger)", marginBottom: "1rem" }}>
{error ?? "الرحلة غير موجودة."}
</p>
<button
type="button"
onClick={() => router.back()}
style={{
height: 38, padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600, cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
العودة
</button>
</div>
);
}
return (
<>
<section style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Header ── */}
<header style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}>
{/* Back + trip info */}
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<button
type="button"
onClick={() => router.back()}
style={{
width: 38, height: 38, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", flexShrink: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{/* Trip icon */}
<div style={{
width: 52, height: 52, borderRadius: "var(--radius-lg)",
background: "var(--color-brand-50, #EFF6FF)",
border: "1px solid var(--color-brand-200, #BFDBFE)",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563EB" strokeWidth="1.8">
<rect x="1" y="3" width="15" height="13" rx="2" />
<path d="M16 8h4l3 5v4h-7V8z" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
{trip.title}
</h1>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
{trip.tripNumber}
</span>
{statusConfig && (
<span style={{
borderRadius: "var(--radius-full)",
border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 700,
color: statusConfig.color,
display: "inline-flex",
alignItems: "center",
gap: 5,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
{statusConfig.label}
</span>
)}
{trip.isDeleted && (
<span style={{
borderRadius: "var(--radius-full)",
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: "#DC2626",
}}>
محذوفة
</span>
)}
</div>
</div>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button
type="button"
onClick={() => setDeleteOpen(true)}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "1px solid #FECACA",
background: "#FEF2F2",
fontSize: 13, fontWeight: 700, color: "#DC2626",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
حذف الرحلة
</button>
<button
type="button"
onClick={() => setEditOpen(true)}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<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>
</div>
</header>
{/* ── Stats row ── */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "1rem" }}>
<StatCard
label="المجمّع"
value={String(trip.collectedCount)}
color="#1E40AF"
bg="#EFF6FF"
/>
<StatCard
label="المُسلَّم"
value={String(trip.deliveredCount)}
color="#166534"
bg="#DCFCE7"
/>
<StatCard
label="المُرتجع"
value={String(trip.returnedCount)}
color="#92400E"
bg="#FFFBEB"
/>
<StatCard
label="النقد المحصّل"
value={fmtNumber(trip.totalCashCollected)}
color="#581C87"
bg="#FAF5FF"
/>
</div>
{/* ── Content grid ── */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
{/* Basic Info */}
<SectionCard title="بيانات الرحلة">
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
<DetailRow label="العنوان" value={trip.title} />
<DetailRow label="الحالة" value={statusConfig?.label ?? trip.status} />
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
<DetailRow label="وقت البدء" value={fmtDate(trip.startTime)} />
<DetailRow label="وقت الانتهاء" value={fmtDate(trip.endTime)} />
<DetailRow label="سبب الإنهاء" value={trip.endReason ?? "—"} />
</SectionCard>
{/* Driver */}
<SectionCard title="السائق">
{trip.driver ? (
<>
<DetailRow label="الاسم" value={trip.driver.name} />
<DetailRow label="الجوال" value={trip.driver.phone} mono />
<DetailRow label="البريد" value={trip.driver.email ?? "—"} />
<DetailRow label="رقم الرخصة" value={trip.driver.licenseNumber ?? "—"} mono />
<DetailRow label="رقم البطاقة" value={trip.driver.driverCardNumber ?? "—"} mono />
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} />
</>
) : (
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: 0 }}>لا توجد بيانات للسائق.</p>
)}
</SectionCard>
{/* Car */}
<SectionCard title="السيارة">
{trip.car ? (
<>
<DetailRow label="الماركة" value={trip.car.manufacturer} />
<DetailRow label="الموديل" value={trip.car.model} />
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} />
<DetailRow label="اللون" value={trip.car.color ?? "—"} />
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono />
<DetailRow label="نوع اللوحة" value={trip.car.plateType ?? "—"} />
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono />
</>
) : (
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: 0 }}>لا توجد بيانات للسيارة.</p>
)}
</SectionCard>
{/* System Info */}
<SectionCard title="معلومات النظام">
<DetailRow label="تاريخ الإضافة" value={fmtDate(trip.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} />
{trip.isDeleted && (
<DetailRow label="تاريخ الحذف" value={fmtDate(trip.deletedAt)} />
)}
</SectionCard>
</div>
{/* ── Notes (full width, if any) ── */}
{trip.notes && (
<SectionCard title="الملاحظات">
<p style={{ fontSize: 13, color: "var(--color-text-primary)", lineHeight: 1.7, margin: 0 }}>
{trip.notes}
</p>
</SectionCard>
)}
{/* ── Report section ── */}
<div style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
}}>
<TripReportPanel tripId={trip.id} />
</div>
</section>
{/* ── Edit modal ── */}
{editOpen && (
<TripFormModal
editTrip={trip}
onClose={() => setEditOpen(false)}
onSubmit={handleEditSubmit}
/>
)}
{/* ── Delete modal ── */}
{deleteOpen && (
<TripDeleteModal
trip={trip}
deleting={deleting}
onCancel={() => setDeleteOpen(false)}
onConfirm={handleConfirmDelete}
/>
)}
</>
);
}

View File

@@ -0,0 +1,3 @@
export default function TripsPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Trips module is ready for scheduling, status tracking, and manifest generation.</section>;
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useState } from "react";
import { Alert } from "@/Components/UI";
import { UserTable } from "@/Components/User/UserTable";
import { UserFormModal } from "@/Components/User/UserFormModal";
import { UserDetailModal } from "@/Components/User/UserDetailModal";
import { DeleteConfirmModal } from "@/Components/User/DeleteConfirmModal";
import { Toast } from "@/Components/User/Toast";
import { useUsers } from "@/hooks/useUser";
import type { User, UserFormData } from "@/types/user";
export default function UsersPage() {
// ── Modal state ─────────────────────────────────────────────────────────────
// false = closed | null = create mode | User = edit mode
const [formTarget, setFormTarget] = useState<User | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
// ID of user whose detail modal is open; null = closed
const [viewUserId, setViewUserId] = useState<string | null>(null);
// Local submitting flag shown in DeleteConfirmModal spinner
const [deleting, setDeleting] = useState(false);
// ── Data hook ───────────────────────────────────────────────────────────────
const {
users, loading, total, pages, error,
roles, branches,
page, search,
setPage, handleSearch, clearError,
createUser, updateUser, deleteUser,
notification,
} = useUsers();
// ── Create / Update handler ─────────────────────────────────────────────────
const handleFormSubmit = async (data: UserFormData, isNew: boolean): Promise<boolean> => {
if (isNew) return createUser(data);
// formTarget is User when editing
return updateUser((formTarget as User).id, data);
};
// ── Delete handler ──────────────────────────────────────────────────────────
const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return;
setDeleting(true);
console.log(setDeleting(true))
const ok = await deleteUser(deleteTarget.id);
console.log(ok)
setDeleting(false);
if (ok) setDeleteTarget(null);
};
// ── Render ──────────────────────────────────────────────────────────────────
return (
<>
{/* Global success / error toast — fires on create, update, AND delete */}
<Toast notification={notification} />
{/* User detail modal */}
{viewUserId && (
<UserDetailModal
userId={viewUserId}
onClose={() => setViewUserId(null)}
/>
)}
{/* Create / Edit modal */}
{formTarget !== false && (
<UserFormModal
editUser={formTarget}
roles={roles}
branches={branches}
onClose={() => setFormTarget(false)}
onSubmit={handleFormSubmit}
/>
)}
{/* Delete confirmation dialog */}
{deleteTarget && (
<DeleteConfirmModal
user={deleteTarget}
deleting={deleting}
onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={handleDeleteConfirm}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */}
<header style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الفريق
</p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
المستخدمون
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> مستخدم
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
{/* Search input */}
<div style={{ position: "relative", width: 256 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف..."
value={search}
onChange={e => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{/* Add user button */}
<button
type="button"
onClick={() => setFormTarget(null)}
style={{
height: 40, padding: "0 1.125rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 7,
fontFamily: "var(--font-sans)",
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
whiteSpace: "nowrap",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة مستخدم
</button>
</div>
</div>
</header>
{/* General load error */}
{error && <Alert type="error" message={error} onClose={clearError} />}
{/* Users table */}
<UserTable
users={users}
loading={loading}
search={search}
page={page}
pages={pages}
onView={user => setViewUserId(user.id)}
onEdit={user => setFormTarget(user)}
onDelete={user => setDeleteTarget(user)}
onAddFirst={() => setFormTarget(null)}
onPageChange={setPage}
/>
</section>
</>
);
}