Files
LogisicsApp_Client/app/dashboard/cars/page.tsx
2026-07-22 17:09:15 +03:00

366 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useState } from "react";
import { Spinner, Alert, ArchiveButton, ConfirmDialog, Toast, EmptyState, Button } from "@/src/Components/UI";
import { CarFormModal,CarDetailPanel } from "@/src/Components/car";
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
import type { Car, CreateCarPayload, UpdateCarPayload } from "@/src/types/car";
import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance";
import Header from "@/src/Components/UI/Header";
// ── CarCard ───────────────────────────────────────────────────────────────────
function CarCard({
car,
onClick,
onSendToMaintenance,
}: {
car: Car;
onClick: () => void;
onSendToMaintenance: (car: Car) => void;
}) {
const status = STATUS_MAP[car.currentStatus];
const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null;
const regWarn = isExpiringSoon(car.registrationExpiryDate);
// Maintenance status can ONLY be set via this button's flow (POST
// cars/:carId/maintenance flips it on the backend). Disable rather than
// hide it once the car is already in maintenance or inactive, and explain why.
const maintenanceDisabled = car.currentStatus === "InMaintenance" || car.currentStatus === "Inactive";
const maintenanceDisabledReason =
car.currentStatus === "InMaintenance"
? "المركبة في الصيانة بالفعل"
: car.currentStatus === "Inactive"
? "لا يمكن إرسال مركبة غير نشطة للصيانة"
: undefined;
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>
{/* Send to Maintenance quick action — kept custom (amber accent isn't
one of Button's variants: primary/secondary/ghost/danger). Same
rationale as IconBtn: forcing it into an existing variant would
misrepresent this as a routine action rather than the
maintenance-triggering one it is. */}
<button
type="button"
title={maintenanceDisabledReason}
disabled={maintenanceDisabled}
onClick={(e) => {
e.stopPropagation();
onSendToMaintenance(car);
}}
style={{
marginTop: "0.875rem",
width: "100%",
height: 34,
borderRadius: "var(--radius-md)",
border: `1px solid ${maintenanceDisabled ? "var(--color-border)" : "#FDE68A"}`,
background: maintenanceDisabled ? "var(--color-surface-muted)" : "#FFFBEB",
fontSize: 12, fontWeight: 700,
color: maintenanceDisabled ? "var(--color-text-hint)" : "#854D0E",
cursor: maintenanceDisabled ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
fontFamily: "var(--font-sans)",
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
إرسال للصيانة
</button>
{/* Footer CTA hint */}
<p style={{ marginTop: "0.75rem", 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 [archiveOpen, setArchiveOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] = 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);
};
// "Send to Maintenance" mutation — carId is bound to whichever car the
// person just clicked. Errors surface inline inside the modal's own
// apiError state (its default behavior), not through the outer toast —
// onError is intentionally a no-op here.
const { handleFormSubmit: handleMaintenanceSubmit } = useCarMaintenanceMutations({
carId: maintenanceTarget?.id ?? "",
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadCars(); },
onError: () => {},
onDeleted: () => {},
getEditTarget: () => null,
});
// ── Render ──────────────────────────────────────────────────────────────────
return (
<>
<Toast 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; })
}
/>
)}
<ConfirmDialog
open={!!deleteTarget}
loading={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title="حذف المركبة"
description={`هل أنت متأكد من حذف ${deleteTarget?.manufacturer ?? ""} ${deleteTarget?.model ?? ""} (${deleteTarget?.plateLetters ?? ""} ${deleteTarget?.plateNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
/>
{maintenanceTarget && (
<CarMaintenanceFormModal
editRecord={null}
carLabel={`${maintenanceTarget.manufacturer} ${maintenanceTarget.model}${maintenanceTarget.plateLetters} ${maintenanceTarget.plateNumber}`}
onClose={() => setMaintenanceTarget(null)}
onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) =>
handleMaintenanceSubmit(payload, isNew)
}
/>
)}
{archiveOpen && (
<ArchivedCarsModal onClose={() => setArchiveOpen(false)} />
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl">
{/* ── Header ── */}
<Header state={{ total }} search={search} setSearch={setSearch} module="" setModule={() => {}} setPage={setPage} title="المركبات" mainTitle="إدارة المركبات" name="مركبة" isAudit={false} onAdd={() => setFormTarget(null)}/>
{/* 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 ? (
<EmptyState
icon="🚗"
title={search ? `لا توجد مركبات تطابق "${search}"` : "لا توجد مركبات بعد"}
description='اضغط على "إضافة مركبة" لإضافة أول مركبة في الأسطول.'
action={!search && (
<Button variant="primary" onClick={() => setFormTarget(null)}>
إضافة مركبة
</Button>
)}
/>
) : (
/* ── 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)}
onSendToMaintenance={(c) => setMaintenanceTarget(c)}
/>
))}
</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}
variant="secondary"
size="sm"
onClick={btn.action}
disabled={btn.disabled}
>
{btn.label}
</Button>
))}
</div>
</div>
)}
</section>
{/* Floating button to open the archive browser */}
<ArchiveButton onClick={() => setArchiveOpen(true)} />
</>
);
}