From a7e53f204726b8b1919479142693f880efc6e98b Mon Sep 17 00:00:00 2001 From: m7amedez1122 Date: Mon, 6 Jul 2026 17:41:12 +0300 Subject: [PATCH] add archive featcher to role , brtanch , car_mainte also update client and clien_addres create car mainte fails and update car fails delete car_maint from saidbar and but it in car model also update car image display --- app/components/layout/Sidebar.tsx | 6 - app/dashboard/branches/page.tsx | 14 +- .../maintenance/[maintenanceId]/page.tsx | 101 ++++++ .../cars/[carId]/maintenance/page.tsx | 214 ++++++++++++ app/dashboard/cars/page.tsx | 80 ++++- app/dashboard/cars_maintenance/page.tsx | 7 - .../clients/[clientId]/addresses/page.tsx | 16 +- app/dashboard/clients/page.tsx | 16 +- app/dashboard/roles/page.tsx | 14 +- .../archive/ArchivedBranchDetailModal.tsx | 206 ++++++++++++ .../Branch/archive/ArchivedBranchTable.tsx | 134 ++++++++ .../Branch/archive/ArchivedBranchesModal.tsx | 117 +++++++ .../CarMaintananceDeleteModal.tsx | 92 ++++- .../CarMaintananceDetailPanel.tsx | 12 +- .../CarMaintananceFormModal.tsx | 31 +- .../ArchivedCarMaintananceDetailpanel.tsx | 138 ++++++++ .../archive/ArchivedCarMaintananceTable.tsx | 127 +++++++ .../archive/ArchivedCarsMaintananceModal.tsx | 122 +++++++ src/Components/Car_Maintanance/index.ts | 3 + src/Components/Client/ClientAddressForm.tsx | 60 +++- src/Components/Client/Clientformmodal.tsx | 21 +- .../archive/ArchivedClientDetailModal.tsx | 317 ++++++++++++++++++ .../Client/archive/ArchivedClientTable.tsx | 134 ++++++++ .../Client/archive/ArchivedClientsModal.tsx | 114 +++++++ .../Client_Adress/Addressformmodal.tsx | 26 +- .../ArchivedClientAddressesDetailModal.tsx | 172 ++++++++++ .../archive/ArchivedClientAddressesTable.tsx | 115 +++++++ .../archive/ArchivedClientsAddressesModal.tsx | 115 +++++++ src/Components/car/CarDetailPanel.tsx | 60 ++-- src/Components/car/CarImageGallery.tsx | 49 ++- .../role/archive/ArchivedRoleDetailModal.tsx | 233 +++++++++++++ .../role/archive/ArchivedRoleTable.tsx | 131 ++++++++ .../role/archive/ArchivedRolesModal.tsx | 115 +++++++ src/hooks/UseCarsMaintanance.ts | 101 +++++- .../archive/UseArchivedCarsMaintanance.ts | 75 +++++ src/hooks/archive/Usearchivedcars.ts | 7 +- src/hooks/archive/useArchiveBranch.ts | 43 +++ src/hooks/archive/useArchiveClient.ts | 64 ++++ src/hooks/archive/useArchiveClientAdresses.ts | 65 ++++ src/hooks/archive/useArchiveRole.ts | 81 +++++ src/hooks/archive/useArchivedClients.ts | 43 +++ src/hooks/useCars.ts | 28 +- src/hooks/useRole.ts | 1 + src/services/api.ts | 19 +- .../archive/archivedBranch.service.ts | 19 ++ .../archive/archivedClient.service.ts | 34 ++ .../archive/archivedClientAdresses.service.ts | 16 + src/services/archive/archivedRole.service.ts | 19 ++ src/services/carMaintanance.service.ts | 15 +- src/services/client.service.ts | 15 +- src/types/branch.ts | 40 +++ src/types/carMaintanance.ts | 55 ++- src/types/client.ts | 72 +++- src/types/client_adresses.ts | 49 ++- src/types/role.ts | 22 ++ src/validations/client.validator.ts | 16 + 56 files changed, 3864 insertions(+), 147 deletions(-) create mode 100644 app/dashboard/cars/[carId]/maintenance/[maintenanceId]/page.tsx create mode 100644 app/dashboard/cars/[carId]/maintenance/page.tsx delete mode 100644 app/dashboard/cars_maintenance/page.tsx create mode 100644 src/Components/Branch/archive/ArchivedBranchDetailModal.tsx create mode 100644 src/Components/Branch/archive/ArchivedBranchTable.tsx create mode 100644 src/Components/Branch/archive/ArchivedBranchesModal.tsx create mode 100644 src/Components/Client/archive/ArchivedClientDetailModal.tsx create mode 100644 src/Components/Client/archive/ArchivedClientTable.tsx create mode 100644 src/Components/Client/archive/ArchivedClientsModal.tsx create mode 100644 src/Components/Client_Adress/archive/ArchivedClientAddressesDetailModal.tsx create mode 100644 src/Components/Client_Adress/archive/ArchivedClientAddressesTable.tsx create mode 100644 src/Components/Client_Adress/archive/ArchivedClientsAddressesModal.tsx create mode 100644 src/Components/role/archive/ArchivedRoleDetailModal.tsx create mode 100644 src/Components/role/archive/ArchivedRoleTable.tsx create mode 100644 src/Components/role/archive/ArchivedRolesModal.tsx create mode 100644 src/hooks/archive/useArchivedClients.ts diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index dccf45b..585ad3d 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -57,12 +57,6 @@ const navSections = [ icon: "ti-car", permission: "read-car", }, - { - href: "/dashboard/cars_maintenance", - label: "صيانة السيارات", - icon: "ti-tool", - permission: "read-maintenance", - }, { href: "/dashboard/drivers", label: "السائقون", diff --git a/app/dashboard/branches/page.tsx b/app/dashboard/branches/page.tsx index eaf3efd..efa1cf9 100644 --- a/app/dashboard/branches/page.tsx +++ b/app/dashboard/branches/page.tsx @@ -1,13 +1,15 @@ "use client"; import { useState } from "react"; -import { Alert, Toast } from "@/src/Components/UI"; +import { Alert, Toast, ArchiveButton } from "@/src/Components/UI"; import { BranchTable } from "@/src/Components/Branch/BranchTable"; import { BranchFormModal } from "@/src/Components/Branch/BranchFormModal"; import { BranchDetailModal } from "@/src/Components/Branch/BranchDetailModal"; import { DeleteConfirmModal } from "@/src/Components/Branch/DeleteConfirmModal"; import { useBranches } from "@/src/hooks/useBranch"; import type { Branch, BranchFormData } from "@/src/types/branch"; +import { ArchivedBranchesModal } from "@/src/Components/Branch/archive/ArchivedBranchesModal"; + export default function BranchesPage() { // ── Modal state ───────────────────────────────────────────────────────────── @@ -16,6 +18,8 @@ export default function BranchesPage() { const [deleteTarget, setDeleteTarget] = useState(null); // ID of branch whose detail modal is open; null = closed const [viewBranchId, setViewBranchId] = useState(null); + // Archive browser modal open/closed + const [archiveOpen, setArchiveOpen] = useState(false); // Local submitting flag shown in DeleteConfirmModal spinner const [deleting, setDeleting] = useState(false); @@ -81,6 +85,11 @@ export default function BranchesPage() { /> )} + {/* Archive browser modal */} + {archiveOpen && ( + setArchiveOpen(false)} /> + )} +
{/* ── Page header ── */} @@ -175,6 +184,9 @@ export default function BranchesPage() { onPageChange={setPage} />
+ + {/* Floating button to open the archive browser */} + setArchiveOpen(true)} /> ); } \ No newline at end of file diff --git a/app/dashboard/cars/[carId]/maintenance/[maintenanceId]/page.tsx b/app/dashboard/cars/[carId]/maintenance/[maintenanceId]/page.tsx new file mode 100644 index 0000000..69ab459 --- /dev/null +++ b/app/dashboard/cars/[carId]/maintenance/[maintenanceId]/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { Alert, PageHeader, Spinner } from "@/src/Components/UI"; +import { useCarMaintenanceList } from "@/src/hooks/UseCarsMaintanance"; +import { + MAINTENANCE_STATUS_MAP, + fmtDate, + fmtCost, + durationDays, + getMaintenanceStatus, +} from "@/src/types/carMaintanance"; + +function DetailRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +export default function CarMaintenanceDetailPage() { + const { carId, maintenanceId } = useParams<{ carId: string; maintenanceId: string }>(); + const router = useRouter(); + + // NOTE: GET /cars/:carId/maintenance/:maintenanceId isn't wired up on the + // backend yet (returns "Route not found"), so this page resolves the + // record from the already-loaded list instead of a dedicated single-record + // call. includeDeleted: true so an archived record still resolves to a + // real "مؤرشفة" status instead of silently disappearing. + const { records, loading, error } = useCarMaintenanceList(carId, { includeDeleted: true }); + const record = records.find((r) => r.id === maintenanceId) ?? null; + + return ( +
+ + +
+ {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {error && } + + {!loading && !error && !record && ( +

+ لم يتم العثور على سجل الصيانة. +

+ )} + + {!loading && !error && record && (() => { + const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)]; + return ( + <> +
+ + {status.label} + +
+ + + + + + + {record.car && ( + + )} + + + + ); + })()} +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/dashboard/cars/[carId]/maintenance/page.tsx b/app/dashboard/cars/[carId]/maintenance/page.tsx new file mode 100644 index 0000000..aa30e13 --- /dev/null +++ b/app/dashboard/cars/[carId]/maintenance/page.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Alert, ArchiveButton, PageHeader, Spinner, Toast } from "@/src/Components/UI"; +import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal"; +import { CarMaintenanceDeleteModal } from "@/src/Components/Car_Maintanance/CarMaintananceDeleteModal"; +import { ArchivedCarsMaintenanceModal } from "@/src/Components/Car_Maintanance/archive/ArchivedCarsMaintananceModal"; +import { + useCarMaintenanceList, + useCarMaintenanceMutations, + useMaintenanceToast, +} from "@/src/hooks/UseCarsMaintanance"; +import { useCarDetail } from "@/src/hooks/useCars"; +import { + MAINTENANCE_STATUS_MAP, + fmtDate, + fmtCost, + durationDays, + getMaintenanceStatus, +} from "@/src/types/carMaintanance"; +import type { + CarMaintenance, + CreateMaintenancePayload, + UpdateMaintenancePayload, +} from "@/src/types/carMaintanance"; + +function MaintenanceCard({ + record, onView, onEdit, onDelete, +}: { + record: CarMaintenance; + onView: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)]; + + return ( +
+
+
+
+

+ {record.reason} +

+ + {status.label} + +
+ +
+
+ التكلفة + {fmtCost(record.cost)} +
+
+ المدة + {durationDays(record.startAt, record.endAt)} يوم +
+
+ تاريخ البدء + {fmtDate(record.startAt)} +
+
+ تاريخ الانتهاء + {fmtDate(record.endAt)} +
+
+ +
+ + + +
+
+
+ ); +} + +export default function CarMaintenancePage() { + const { carId } = useParams<{ carId: string }>(); + const router = useRouter(); + + const { car, refetch: refetchCar } = useCarDetail(carId); + const carLabel = car ? `${car.manufacturer} ${car.model} — ${car.plateLetters} ${car.plateNumber}` : undefined; + + const { records, loading, error, loadRecords, removeRecord, setError } = useCarMaintenanceList(carId); + const { toast, notify } = useMaintenanceToast(); + + const [formTarget, setFormTarget] = useState(false); // false = closed + const [deleteTarget, setDeleteTarget] = useState(null); + const [archiveOpen, setArchiveOpen] = useState(false); + + const getEditTarget = useCallback( + () => (formTarget instanceof Object && formTarget !== null ? (formTarget as CarMaintenance) : null), + [formTarget], + ); + + const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMaintenanceMutations({ + carId, + onSuccess: (msg) => { notify({ type: "success", message: msg }); loadRecords(); refetchCar(); }, + onError: (msg) => notify({ type: "error", message: msg }), + onDeleted: (id) => { removeRecord(id); setDeleteTarget(null); refetchCar(); }, + getEditTarget, + }); + + return ( + <> + + + {formTarget !== false && ( + setFormTarget(false)} + onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) => + handleFormSubmit(payload, isNew).then(ok => { if (ok) setFormTarget(false); return ok; }) + } + /> + )} + + {deleteTarget && ( + setDeleteTarget(null)} + onConfirm={() => handleDeleteConfirm(deleteTarget)} + /> + )} + + {archiveOpen && ( + setArchiveOpen(false)} + /> + )} + +
+ 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)", whiteSpace: "nowrap", + }}> + + + + إضافة سجل صيانة + + } + /> + + {error && setError(null)} />} + + {loading ? ( +
+ + جارٍ تحميل سجلات الصيانة… +
+ ) : records.length === 0 ? ( +
+
🔧
+

لا توجد سجلات صيانة بعد

+

+ اضغط على "إضافة سجل صيانة" لتسجيل أول عملية صيانة لهذه المركبة. +

+
+ ) : ( +
+ {records.map(record => ( + router.push(`/dashboard/cars/${carId}/maintenance/${record.id}`)} + onEdit={() => setFormTarget(record)} + onDelete={() => setDeleteTarget(record)} + /> + ))} +
+ )} +
+ + setArchiveOpen(true)} label="أرشيف الصيانة" /> + + ); +} \ No newline at end of file diff --git a/app/dashboard/cars/page.tsx b/app/dashboard/cars/page.tsx index 9c9acbf..7a4cfab 100644 --- a/app/dashboard/cars/page.tsx +++ b/app/dashboard/cars/page.tsx @@ -7,9 +7,12 @@ import { CarFormModal } from "@/src/Components/car/CarFormModal"; import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel"; import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal"; 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, ToastMsg, UpdateCarPayload } from "@/src/types/car"; +import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance"; // ── Toast ───────────────────────────────────────────────────────────────────── @@ -41,11 +44,30 @@ function CarToast({ notification }: { notification: ToastMsg | null }) { // ── CarCard ─────────────────────────────────────────────────────────────────── -function CarCard({ car, onClick }: { car: Car; onClick: () => void }) { +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 (
void }) { + {/* Send to Maintenance quick action */} + + {/* Footer CTA hint */} -

+

اضغط لعرض التفاصيل ←

@@ -171,6 +222,7 @@ export default function CarsPage() { const [formTarget, setFormTarget] = useState(false); // false = closed const [deleteTarget, setDeleteTarget] = useState(null); const [archiveOpen, setArchiveOpen] = useState(false); + const [maintenanceTarget, setMaintenanceTarget] = useState(null); const { toast, notify } = useToast(); @@ -194,6 +246,18 @@ export default function CarsPage() { 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 ( <> @@ -228,6 +292,17 @@ export default function CarsPage() { /> )} + {maintenanceTarget && ( + setMaintenanceTarget(null)} + onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) => + handleMaintenanceSubmit(payload, isNew) + } + /> + )} + {archiveOpen && ( setArchiveOpen(false)} /> )} @@ -357,6 +432,7 @@ export default function CarsPage() { key={car.id} car={car} onClick={() => setDetailId(car.id)} + onSendToMaintenance={(c) => setMaintenanceTarget(c)} /> ))} diff --git a/app/dashboard/cars_maintenance/page.tsx b/app/dashboard/cars_maintenance/page.tsx deleted file mode 100644 index cb2be10..0000000 --- a/app/dashboard/cars_maintenance/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react' - -export default function page() { - return ( -
page
- ) -} diff --git a/app/dashboard/clients/[clientId]/addresses/page.tsx b/app/dashboard/clients/[clientId]/addresses/page.tsx index f567b70..8410c72 100644 --- a/app/dashboard/clients/[clientId]/addresses/page.tsx +++ b/app/dashboard/clients/[clientId]/addresses/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; -import { Alert, Toast } from "@/src/Components/UI"; +import { Alert, Toast, ArchiveButton } from "@/src/Components/UI"; import { useClientAddresses } from "@/src/hooks/useClientAddresses"; import { clientService } from "@/src/services/client.service"; @@ -13,6 +13,7 @@ import type { Client, ClientFormData } from "@/src/types/client"; import type { ClientAddress } from "@/src/types/client_adresses"; import { AddressFormModal, DeleteConfirmModal, ClientFormModal } from "@/src/Components/Client"; +import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal"; import type { CreateAddressFormValues, UpdateAddressFormValues, @@ -352,6 +353,8 @@ export default function ClientAddressesPage() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [editingClient, setEditingClient] = useState(false); + // Archive browser modal open/closed — scoped to this client's addresses + const [archiveOpen, setArchiveOpen] = useState(false); // ── Address form handler ───────────────────────────────────────────────── const handleAddressSubmit = async ( @@ -445,6 +448,14 @@ export default function ClientAddressesPage() { /> )} + {/* Archived addresses browser — scoped to this client */} + {archiveOpen && ( + setArchiveOpen(false)} + /> + )} +
{/* ── Header ── */} @@ -655,6 +666,9 @@ export default function ClientAddressesPage() { )}
+ + {/* Floating button to open the address archive for this client */} + setArchiveOpen(true)} label="أرشيف العناوين" /> ); } \ No newline at end of file diff --git a/app/dashboard/clients/page.tsx b/app/dashboard/clients/page.tsx index e32dcfb..dd5ebf5 100644 --- a/app/dashboard/clients/page.tsx +++ b/app/dashboard/clients/page.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; // ── UI components ────────────────────────────────────────────────────────── // NOTE: Toast is now imported from the canonical UI barrel, NOT from Client/Toast. -import { Alert, Toast } from "@/src/Components/UI"; +import { Alert, Toast, ArchiveButton } from "@/src/Components/UI"; // ── Client-specific components ───────────────────────────────────────────── import { useClients } from "@/src/hooks/useClients"; @@ -14,6 +14,8 @@ import type { Client, ClientFormData } from "@/src/types/client"; import { ClientFormModal } from "@/src/Components/Client/Clientformmodal"; import { ClientTable } from "@/src/Components/Client/Clienttable"; import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal"; +import { ArchivedClientsModal } from "@/src/Components/Client/archive/ArchivedClientsModal"; + export default function ClientsPage() { const router = useRouter(); @@ -23,6 +25,8 @@ export default function ClientsPage() { const [formTarget, setFormTarget] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); + // Archive browser modal open/closed + const [archiveOpen, setArchiveOpen] = useState(false); // ── Data hook ──────────────────────────────────────────────────────────── const { @@ -87,6 +91,11 @@ export default function ClientsPage() { /> )} + {/* Archive browser modal */} + {archiveOpen && ( + setArchiveOpen(false)} /> + )} +
{/* ── Page header ── */} @@ -244,6 +253,9 @@ export default function ClientsPage() { onManageAddresses={handleManageAddresses} />
+ + {/* Floating button to open the archive browser */} + setArchiveOpen(true)} label="أرشيف العملاء" /> ); -} \ No newline at end of file +} diff --git a/app/dashboard/roles/page.tsx b/app/dashboard/roles/page.tsx index be9c652..7b84745 100644 --- a/app/dashboard/roles/page.tsx +++ b/app/dashboard/roles/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Alert } from "@/src/Components/UI"; +import { Alert, ArchiveButton } from "@/src/Components/UI"; import { RoleTable } from "@/src/Components/role/RoleTable"; import { RoleFormModal } from "@/src/Components/role/RoleFormModal"; import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal"; @@ -9,6 +9,8 @@ import { DeleteRoleModal } from "@/src/Components/role/DeleteRoleModal"; import { Toast } from "@/src/Components/UI"; import { useRoles } from "@/src/hooks/useRole"; import { Role, RoleFormData } from "@/src/types/role"; +import { ArchivedRolesModal } from "@/src/Components/role/archive/ArchivedRolesModal"; + export default function RolesPage() { // ── Modal state ────────────────────────────────────────────────────────────── @@ -17,6 +19,8 @@ export default function RolesPage() { const [deleteTarget, setDeleteTarget] = useState(null); const [viewRoleId, setViewRoleId] = useState(null); const [deleting, setDeleting] = useState(false); + // Archive browser modal open/closed + const [archiveOpen, setArchiveOpen] = useState(false); // ── Data hook ──────────────────────────────────────────────────────────────── const { @@ -82,6 +86,11 @@ export default function RolesPage() { /> )} + {/* Archive browser modal */} + {archiveOpen && ( + setArchiveOpen(false)} /> + )} +
{/* Page header */} @@ -175,6 +184,9 @@ export default function RolesPage() { onPageChange={setPage} />
+ + {/* Floating button to open the archive browser */} + setArchiveOpen(true)} /> ); } \ No newline at end of file diff --git a/src/Components/Branch/archive/ArchivedBranchDetailModal.tsx b/src/Components/Branch/archive/ArchivedBranchDetailModal.tsx new file mode 100644 index 0000000..95c11e2 --- /dev/null +++ b/src/Components/Branch/archive/ArchivedBranchDetailModal.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect } from "react"; +import type { ArchivedBranch } from "@/src/types/branch"; + +interface ArchivedBranchDetailModalProps { + branch: ArchivedBranch; + onClose: () => void; +} + +// ── small helper components ─────────────────────────────────────────────────── +function DetailRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ + {label} + + + {value || "—"} + +
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +function BranchIcon() { + return ( +
+ + + + +
+ ); +} + +// ── main component ──────────────────────────────────────────────────────────── +// NOTE: there is no GET /branches/archived/{id} route on the backend, so this +// modal does NOT fetch — it just renders the ArchivedBranch object that was +// already loaded from GET /branches/archived (passed down from the table). +export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDetailModalProps) { + // close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + // ── helpers ─────────────────────────────────────────────────────────────── + const fmt = (iso?: string | null) => + iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; + + const fullAddress = (b: ArchivedBranch) => + [b.street, b.district, b.city, b.state, b.country].filter(Boolean).join("، ") || null; + + // ── render ──────────────────────────────────────────────────────────────── + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 55, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 480, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > + {/* ── header ── */} +
+
+

+ فرع مؤرشف +

+

+ {branch.name} +

+
+ +
+ + {/* ── body ── */} +
+
+ + {/* icon + name + status row */} +
+ +
+

{branch.name}

+

+ #{branch.id} +

+
+ +
+
+
+ + {/* detail rows — every field requested */} + + + + + + + + + + + + + + +
+
+ + {/* ── footer ── */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Branch/archive/ArchivedBranchTable.tsx b/src/Components/Branch/archive/ArchivedBranchTable.tsx new file mode 100644 index 0000000..e16e142 --- /dev/null +++ b/src/Components/Branch/archive/ArchivedBranchTable.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { Spinner } from "../../UI"; +import type { ArchivedBranch } from "@/src/types/branch"; + +// ── status badge ───────────────────────────────────────────────────────────── +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +// ── icon button ────────────────────────────────────────────────────────────── +function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { + return ( + + ); +} + +// ── card / header styles ───────────────────────────────────────────────────── +const cardStyle: React.CSSProperties = { + borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", + background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", +}; +const thStyle: React.CSSProperties = { + padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700, + textTransform: "uppercase", letterSpacing: "0.2em", + color: "var(--color-text-muted)", background: "var(--color-surface-muted)", + borderBottom: "1px solid var(--color-border)", +}; + +// ── Props ──────────────────────────────────────────────────────────────────── +interface ArchivedBranchTableProps { + branches: ArchivedBranch[]; + loading: boolean; + search: string; + page: number; + pages: number; + onView: (branch: ArchivedBranch) => void; + onPageChange: (p: number) => void; +} + +// ── main table ─────────────────────────────────────────────────────────────── +export function ArchivedBranchTable({ branches, loading, search, page, pages, onView, onPageChange }: ArchivedBranchTableProps) { + return ( +
+ {/* column headers */} +
+ الفرع + المدينة + الحالة + تاريخ الإنشاء + آخر تحديث + إجراءات +
+ + {/* loading state */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : branches.length === 0 ? ( + /* empty state */ +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع في الأرشيف."} +

+
+ ) : ( + /* data rows */ +
    + {branches.map((b, i) => ( +
  • +
    +

    {b.name}

    +

    {b.street}

    +
    + {b.city || "—"} + + + {new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + + + {new Date(b.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + +
    + {/* view only — delete/restore not implemented yet */} + onView(b)}> + + + + + +
    +
  • + ))} +
+ )} + + {/* pagination */} + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/Branch/archive/ArchivedBranchesModal.tsx b/src/Components/Branch/archive/ArchivedBranchesModal.tsx new file mode 100644 index 0000000..e7f8d71 --- /dev/null +++ b/src/Components/Branch/archive/ArchivedBranchesModal.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedBranchTable } from "./ArchivedBranchTable"; +import { ArchivedBranchDetailModal } from "./ArchivedBranchDetailModal"; +import { useArchivedBranches } from "@/src/hooks/archive/useArchiveBranch"; +import type { ArchivedBranch } from "@/src/types/branch"; + +interface ArchivedBranchesModalProps { + onClose: () => void; +} + +export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) { + // hold the full object, not just the id — no GET /branches/archived/{id} route exists + const [viewBranch, setViewBranch] = useState(null); + + const { + branches, loading, total, pages, error, + page, search, + setPage, handleSearch, clearError, + } = useArchivedBranches(); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewBranch && ( + setViewBranch(null)} /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 920, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+
+

+ الأرشيف +

+

+ الفروع المؤرشفة + + ({total}) + +

+
+ +
+ + {/* body */} +
+ {/* search */} +
+ + + + 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)", + }} + /> +
+ + {/* general fetch error — fallback UI */} + {error && } + + setViewBranch(branch)} + onPageChange={setPage} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Car_Maintanance/CarMaintananceDeleteModal.tsx b/src/Components/Car_Maintanance/CarMaintananceDeleteModal.tsx index 0519ecb..856150a 100644 --- a/src/Components/Car_Maintanance/CarMaintananceDeleteModal.tsx +++ b/src/Components/Car_Maintanance/CarMaintananceDeleteModal.tsx @@ -1 +1,91 @@ - \ No newline at end of file + "use client"; + +import { useEffect } from "react"; +import { Spinner } from "../UI"; +import { fmtCost } from "@/src/types/carMaintanance"; +import type { CarMaintenance } from "@/src/types/carMaintanance"; + +interface CarMaintenanceDeleteModalProps { + record: CarMaintenance; + deleting: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function CarMaintenanceDeleteModal({ + record, + deleting, + onCancel, + onConfirm, +}: CarMaintenanceDeleteModalProps) { + useEffect(() => { + const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, [onCancel]); + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + style={{ + position: "fixed", inset: 0, zIndex: 60, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 420, + background: "var(--color-surface)", + borderRadius: "var(--radius-xl)", + border: "1px solid #FECACA", + boxShadow: "0 20px 48px rgba(0,0,0,.18)", + padding: "2rem", + display: "flex", flexDirection: "column", gap: "1rem", + textAlign: "center", + }} + > + {/* Icon */} +
+ + + + + + +
+ +
+

+ حذف سجل الصيانة +

+

+ هل أنت متأكد من حذف سجل{" "} + + {record.reason} + + {" "}( + + {fmtCost(record.cost)} + + )؟ لا يمكن التراجع عن هذا الإجراء. +

+
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx b/src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx index 6bb2743..7b99da5 100644 --- a/src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx +++ b/src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx @@ -23,7 +23,6 @@ import type { } from "@/src/types/carMaintanance"; // ── Toast ───────────────────────────────────────────────────────────────────── -// Small floating message that confirms an action worked (or explains why it didn't). function MaintenanceToast({ notification }: { notification: { type: "success" | "error"; message: string } | null }) { if (!notification) return null; @@ -127,11 +126,16 @@ interface CarMaintenanceDetailPanelProps { /** e.g. "تويوتا لاند كروزر — أ ب ج 1234", shown in the header. */ carLabel?: string; onClose: () => void; + /** Called after any maintenance create/update/delete succeeds, since those + * operations can also change the parent car's currentStatus on the backend. + * The caller should use this to refetch the car it's displaying elsewhere + * (e.g. in a CarDetailPanel shown alongside this one). */ + onCarStatusChanged?: () => void; } // ── Component ───────────────────────────────────────────────────────────────── -export function CarMaintenanceDetailPanel({ carId, carLabel, onClose }: CarMaintenanceDetailPanelProps) { +export function CarMaintenanceDetailPanel({ carId, carLabel, onClose, onCarStatusChanged }: CarMaintenanceDetailPanelProps) { const { records, loading, error, loadRecords, removeRecord } = useCarMaintenanceList(carId); const { toast, notify } = useMaintenanceToast(); @@ -145,9 +149,9 @@ export function CarMaintenanceDetailPanel({ carId, carLabel, onClose }: CarMaint const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMaintenanceMutations({ carId, - onSuccess: (msg) => { notify({ type: "success", message: msg }); loadRecords(); }, + onSuccess: (msg) => { notify({ type: "success", message: msg }); loadRecords(); onCarStatusChanged?.(); }, onError: (msg) => notify({ type: "error", message: msg }), - onDeleted: (id) => { removeRecord(id); setDeleteTarget(null); }, + onDeleted: (id) => { removeRecord(id); setDeleteTarget(null); onCarStatusChanged?.(); }, getEditTarget, }); diff --git a/src/Components/Car_Maintanance/CarMaintananceFormModal.tsx b/src/Components/Car_Maintanance/CarMaintananceFormModal.tsx index 0db26a7..9b27dce 100644 --- a/src/Components/Car_Maintanance/CarMaintananceFormModal.tsx +++ b/src/Components/Car_Maintanance/CarMaintananceFormModal.tsx @@ -44,6 +44,21 @@ const errorTextStyle: React.CSSProperties = { fontWeight: 500, }; +// ── Number coercion helper ──────────────────────────────────────────────────── +// The backend can return `cost` as a numeric string (common with Decimal +// columns getting JSON-serialized as strings), even though our TS type says +// `number`. If that untouched value round-trips back out on update without +// ever passing through the `` onChange handler, it +// stays a string and fails the backend's strict `z.number()` check. Coerce +// defensively both when hydrating the form AND right before building the +// submit payload, so this can never happen regardless of the source. + +function toNumberOrUndefined(v: unknown): number | undefined { + if (v === undefined || v === null || v === "") return undefined; + const n = typeof v === "number" ? v : Number(v); + return Number.isNaN(n) ? undefined : n; +} + // ── yup validation ──────────────────────────────────────────────────────────── // Checks the form values against the right schema and turns any problems // into a simple field -> message map the inputs below can read from. @@ -103,7 +118,9 @@ export function CarMaintenanceFormModal({ // ── Form state ───────────────────────────────────────────────────────────── const [reason, setReason] = useState(editRecord?.reason ?? ""); - const [cost, setCost] = useState(editRecord?.cost ?? undefined); + // Coerced defensively: editRecord.cost may arrive as a numeric string from + // the backend (e.g. a Decimal column serialized to JSON as "150"). + const [cost, setCost] = useState(toNumberOrUndefined(editRecord?.cost)); const [startAt, setStartAt] = useState(editRecord?.startAt?.slice(0, 10) ?? ""); const [endAt, setEndAt] = useState(editRecord?.endAt?.slice(0, 10) ?? ""); @@ -135,10 +152,15 @@ export function CarMaintenanceFormModal({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + // Belt-and-braces: re-coerce cost right before it's used, in case it + // somehow slipped back into a string (e.g. untouched value hydrated + // from a record whose `cost` came back as a numeric string). + const safeCost = toNumberOrUndefined(cost); + // Build a snapshot for yup — include everything so optional rules also run. const formSnapshot: Partial = { reason, - cost, + cost: safeCost, ...(startAt && { startAt }), ...(endAt && { endAt }), }; @@ -149,8 +171,9 @@ export function CarMaintenanceFormModal({ return; } - // Build the final payload — dates go out as full ISO-8601 strings. - const raw: Record = { reason, cost }; + // Build the final payload — dates go out as full ISO-8601 strings, and + // cost always goes out as a real number, never a string. + const raw: Record = { reason, cost: safeCost }; if (startAt) raw.startAt = toIsoDateTime(startAt); if (endAt) raw.endAt = toIsoDateTime(endAt); diff --git a/src/Components/Car_Maintanance/archive/ArchivedCarMaintananceDetailpanel.tsx b/src/Components/Car_Maintanance/archive/ArchivedCarMaintananceDetailpanel.tsx index e69de29..eb1dc86 100644 --- a/src/Components/Car_Maintanance/archive/ArchivedCarMaintananceDetailpanel.tsx +++ b/src/Components/Car_Maintanance/archive/ArchivedCarMaintananceDetailpanel.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect } from "react"; +import { Spinner } from "../../UI"; +import { + MAINTENANCE_STATUS_MAP, + fmtDate, + fmtCost, + durationDays, + getMaintenanceStatus, +} from "@/src/types/carMaintanance"; +import type { CarMaintenance } from "@/src/types/carMaintanance"; + +interface ArchivedCarMaintenanceDetailPanelProps { + record: CarMaintenance | null; + loading: boolean; + error: string | null; + onClose: () => void; +} + +function DetailRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onClose }: ArchivedCarMaintenanceDetailPanelProps) { + useEffect(() => { + const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, [onClose]); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 80, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 480, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > +
+
+

+ سجل صيانة مؤرشف +

+

+ {record ? record.reason : "عرض السجل"} +

+
+ +
+ +
+ {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && record && (() => { + const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)]; + return ( + <> +
+ + {status.label} + + + مؤرشف + +
+ + {record.car && ( + + )} + + + + + + + + ); + })()} +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Car_Maintanance/archive/ArchivedCarMaintananceTable.tsx b/src/Components/Car_Maintanance/archive/ArchivedCarMaintananceTable.tsx index e69de29..bef3f3f 100644 --- a/src/Components/Car_Maintanance/archive/ArchivedCarMaintananceTable.tsx +++ b/src/Components/Car_Maintanance/archive/ArchivedCarMaintananceTable.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { Spinner } from "../../UI"; +import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance"; +import type { CarMaintenance } from "@/src/types/carMaintanance"; + +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)", +}; + +function IconBtn({ onClick, title, color, bg, borderColor, children }: { + onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode; +}) { + return ( + + ); +} + +interface ArchivedCarMaintenanceTableProps { + records: CarMaintenance[]; + loading: boolean; + search: string; + page: number; + pages: number; + /** Only relevant for the global (cross-car) archive view. */ + showCar?: boolean; + onView: (record: CarMaintenance) => void; + onPageChange: (p: number) => void; +} + +export function ArchivedCarMaintenanceTable({ + records, loading, search, page, pages, showCar = false, onView, onPageChange, +}: ArchivedCarMaintenanceTableProps) { + const columns = showCar ? "2fr 1fr 1fr 1fr 1fr 100px" : "2fr 1fr 1fr 1fr 100px"; + + return ( +
+
+ سبب الصيانة + {showCar && المركبة} + التكلفة + تاريخ البدء + تاريخ الانتهاء + إجراءات +
+ + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : records.length === 0 ? ( +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."} +

+
+ ) : ( +
    + {records.map((r, i) => ( +
  • +
    +

    {r.reason}

    +

    أضيف {fmtDateShort(r.createdAt)}

    +
    + {showCar && ( + + {r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"} + + )} + + {fmtCost(r.cost)} + + {fmtDateShort(r.startAt)} + + {fmtDateShort(r.endAt)} + +
    + onView(r)}> + + + + + +
    +
  • + ))} +
+ )} + + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/Car_Maintanance/archive/ArchivedCarsMaintananceModal.tsx b/src/Components/Car_Maintanance/archive/ArchivedCarsMaintananceModal.tsx index e69de29..e620e32 100644 --- a/src/Components/Car_Maintanance/archive/ArchivedCarsMaintananceModal.tsx +++ b/src/Components/Car_Maintanance/archive/ArchivedCarsMaintananceModal.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedCarMaintenanceTable } from "./ArchivedCarMaintananceTable"; +import { ArchivedCarMaintenanceDetailPanel } from "./ArchivedCarMaintananceDetailpanel"; +import { useArchivedCarMaintenance } from "@/src/hooks/archive/UseArchivedCarsMaintanance"; +import type { CarMaintenance } from "@/src/types/carMaintanance"; + +interface ArchivedCarsMaintenanceModalProps { + /** A car's id to scope the archive to one vehicle, or `null` for the global (all-cars) archive. */ + carId: string | null; + /** Shown in the header, e.g. "تويوتا لاند كروزر — أ ب ج 1234". Ignored for the global view. */ + carLabel?: string; + onClose: () => void; +} + +export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: ArchivedCarsMaintenanceModalProps) { + const [viewRecord, setViewRecord] = useState(null); + + const { + records, loading, total, pages, error, + page, search, + setPage, handleSearch, setError, + } = useArchivedCarMaintenance(carId); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewRecord && ( + setViewRecord(null)} + /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 980, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > +
+
+

+ الأرشيف +

+

+ سجلات الصيانة المؤرشفة{carLabel ? ` — ${carLabel}` : ""} + + ({total}) + +

+
+ +
+ +
+
+ + + + 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)", + }} + /> +
+ + {error && setError(null)} />} + + setViewRecord(record)} + onPageChange={setPage} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Car_Maintanance/index.ts b/src/Components/Car_Maintanance/index.ts index e69de29..b076848 100644 --- a/src/Components/Car_Maintanance/index.ts +++ b/src/Components/Car_Maintanance/index.ts @@ -0,0 +1,3 @@ +export { ArchivedCarMaintenanceTable } from "./archive/ArchivedCarMaintananceTable"; +export { ArchivedCarMaintenanceDetailPanel } from "./archive/ArchivedCarMaintananceDetailpanel"; +export { ArchivedCarsMaintenanceModal } from "./archive/ArchivedCarsMaintananceModal"; \ No newline at end of file diff --git a/src/Components/Client/ClientAddressForm.tsx b/src/Components/Client/ClientAddressForm.tsx index 792775e..c544bcc 100644 --- a/src/Components/Client/ClientAddressForm.tsx +++ b/src/Components/Client/ClientAddressForm.tsx @@ -1,5 +1,5 @@ import React, { useState, FormEvent } from "react"; -import { ClientAddress, CreateClientAddressPayload } from "@/src/types/client"; +import type { ClientAddress, CreateClientAddressPayload } from "@/src/types/client_adresses"; import { Input, Select, Button } from "../UI"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -12,11 +12,18 @@ interface ClientAddressFormProps { submitLabel?: string; } -type FormErrors = Partial>; +type FormErrors = Partial<{ + label: string; + street: string; + city: string; + state: string; + postalCode: string; + country: string; +}>; // ─── Validation ─────────────────────────────────────────────────────────────── -function validate(v: CreateClientAddressPayload): FormErrors { +function validate(v: { label: string; street: string; city: string; state: string; postalCode: string; country: string }): FormErrors { const e: FormErrors = {}; if (!v.label.trim()) e.label = "Label is required."; if (!v.street.trim()) e.street = "Street is required."; @@ -39,21 +46,28 @@ export default function ClientAddressForm({ onCancel, submitLabel = "Save address", }: ClientAddressFormProps) { - const [values, setValues] = useState({ - clientId, - label: initialValues.label ?? "", - street: initialValues.street ?? "", - city: initialValues.city ?? "", - state: initialValues.state ?? "", - postalCode: initialValues.postalCode ?? "", - country: initialValues.country ?? "", - isPrimary: initialValues.isPrimary ?? false, + const [values, setValues] = useState<{ + label: string; + street: string; + city: string; + state: string; + postalCode: string; + country: string; + isPrimary: boolean; + }>({ + label: initialValues.label ?? "", + street: initialValues.details?.street ?? "", + city: initialValues.details?.city ?? "", + state: initialValues.details?.state ?? "", + postalCode: initialValues.details?.zipCode ?? "", + country: initialValues.details?.country ?? "", + isPrimary: initialValues.isPrimary ?? false, }); const [errors, setErrors] = useState({}); const [submitting, setSubmitting] = useState(false); const set = - (field: keyof Omit) => + (field: "label" | "street" | "city" | "state" | "postalCode" | "country") => (e: React.ChangeEvent) => { setValues((v) => ({ ...v, [field]: e.target.value })); if (errors[field]) setErrors((er) => ({ ...er, [field]: undefined })); @@ -68,7 +82,25 @@ export default function ClientAddressForm({ } setSubmitting(true); try { - await onSubmit(values); + const payload: CreateClientAddressPayload = { + clientId, + label: values.label, + branchName: null, + contactPerson: undefined, + details: { + country: values.country, + city: values.city, + state: values.state, + street: values.street, + zipCode: values.postalCode, + }, + location: { + coordinates: [0, 0], + }, + isPrimary: values.isPrimary, + isValidated: false, + }; + await onSubmit(payload); } catch { /* parent surfaces the error */ } finally { diff --git a/src/Components/Client/Clientformmodal.tsx b/src/Components/Client/Clientformmodal.tsx index 4a7942b..ed6f8bd 100644 --- a/src/Components/Client/Clientformmodal.tsx +++ b/src/Components/Client/Clientformmodal.tsx @@ -8,10 +8,8 @@ import { createClientSchema, updateClientSchema, CLIENT_TYPES, - type CreateClientFormValues, - type UpdateClientFormValues, } from "@/src/validations/client.validator"; -import type { Client } from "@/src/types/client"; +import type { Client, ClientFormData } from "@/src/types/client"; // ── Styles ───────────────────────────────────────────────────────────────── const S = { @@ -52,7 +50,7 @@ interface ClientFormModalProps { editClient: Client | null; onClose: () => void; onSubmit: ( - data: CreateClientFormValues | UpdateClientFormValues, + data: ClientFormData, isNew: boolean ) => Promise; } @@ -62,20 +60,19 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod const isNew = editClient === null; // FIX 1: was `Schema` (capital S) — now correctly `schema` - const schema = isNew ? createClientSchema : updateClientSchema; - const { register, handleSubmit, setError, formState: { errors, isSubmitting }, - } = useForm({ - resolver: yupResolver(schema) as never, + } = useForm({ + resolver: yupResolver(isNew ? createClientSchema : updateClientSchema) as never, defaultValues: { - name: editClient?.name ?? "", - email: editClient?.email ?? "", - phone: editClient?.phone ?? "", + name: editClient?.name ?? "", + email: editClient?.email ?? "", + phone: editClient?.phone ?? "", clientType: editClient?.clientType ?? undefined, + isActive: editClient?.isActive ?? true, }, }); @@ -85,7 +82,7 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod return () => window.removeEventListener("keydown", handler); }, [onClose]); - const submitHandler = async (data: CreateClientFormValues | UpdateClientFormValues) => { + const submitHandler = async (data: ClientFormData) => { const ok = await onSubmit(data, isNew); if (ok) { onClose(); diff --git a/src/Components/Client/archive/ArchivedClientDetailModal.tsx b/src/Components/Client/archive/ArchivedClientDetailModal.tsx new file mode 100644 index 0000000..acb3bce --- /dev/null +++ b/src/Components/Client/archive/ArchivedClientDetailModal.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useEffect } from "react"; +import { Spinner } from "../../UI"; +import { useArchivedClient } from "@/src/hooks/archive/useArchiveClient"; + +interface ArchivedClientDetailModalProps { + clientId: string; + onClose: () => void; +} + +// ── small helper components ─────────────────────────────────────────────────── +function DetailRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ + {label} + + + {value || "—"} + +
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +function Avatar({ name }: { name: string }) { + const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase(); + return ( +
+ {initials} +
+ ); +} + +// Small badge for an archived order row's status +function OrderStatusBadge({ status }: { status: string }) { + return ( + + {status} + + ); +} + +// ── main component ──────────────────────────────────────────────────────────── +export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientDetailModalProps) { + const { + client, loading, error, + orders, ordersLoading, ordersError, + ordersPage, ordersPages, ordersTotal, + setOrdersPage, + } = useArchivedClient(clientId); + + // close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + const fmt = (iso?: string | null) => + iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; + + // ── render ──────────────────────────────────────────────────────────────── + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 55, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 560, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > + {/* ── header ── */} +
+
+

+ عميل مؤرشف +

+

+ {client?.name ?? "عرض العميل"} +

+
+ +
+ + {/* ── body ── */} +
+ + {/* loading */} + {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {/* error */} + {!loading && error && ( +
+ {error} +
+ )} + + {/* content */} + {!loading && client && ( +
+ {/* avatar + name + status row */} +
+ +
+

{client.name}

+
+ +
+
+
+ + {/* detail rows */} + + + + + + + {client.deletedAt && ( + + )} + + {/* ── addresses ── */} +

+ العناوين ({client.addresses?.length ?? 0}) +

+ {!client.addresses || client.addresses.length === 0 ? ( +

+ لا توجد عناوين مرتبطة بهذا العميل. +

+ ) : ( +
+ {client.addresses.map(addr => ( +
+ {addr.label} + {" — "}{addr.street}، {addr.city} +
+ ))} +
+ )} + + {/* ── archived orders ── */} +

+ الطلبات المؤرشفة ({ordersTotal}) +

+ + {ordersLoading ? ( +
+ + جارٍ تحميل الطلبات… +
+ ) : ordersError ? ( +
{ordersError}
+ ) : orders.length === 0 ? ( +

+ لا توجد طلبات مؤرشفة لهذا العميل. +

+ ) : ( + <> +
    + {orders.map(o => ( +
  • + + {o.orderNumber ?? o.id} + + + + {new Date(o.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + +
  • + ))} +
+ + {ordersPages > 1 && ( +
+ + صفحة {ordersPage} من {ordersPages} + +
+ + +
+
+ )} + + )} +
+ )} +
+ + {/* ── footer ── */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Client/archive/ArchivedClientTable.tsx b/src/Components/Client/archive/ArchivedClientTable.tsx new file mode 100644 index 0000000..a69ec07 --- /dev/null +++ b/src/Components/Client/archive/ArchivedClientTable.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { Spinner } from "../../UI"; +import type { ArchivedClient } from "@/src/types/client"; + +// ── status badge ───────────────────────────────────────────────────────────── +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +// ── icon button ────────────────────────────────────────────────────────────── +function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { + return ( + + ); +} + +// ── card / header styles ───────────────────────────────────────────────────── +const cardStyle: React.CSSProperties = { + borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", + background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", +}; +const thStyle: React.CSSProperties = { + padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700, + textTransform: "uppercase", letterSpacing: "0.2em", + color: "var(--color-text-muted)", background: "var(--color-surface-muted)", + borderBottom: "1px solid var(--color-border)", +}; + +// ── Props ──────────────────────────────────────────────────────────────────── +interface ArchivedClientTableProps { + clients: ArchivedClient[]; + loading: boolean; + search: string; + page: number; + pages: number; + onView: (client: ArchivedClient) => void; + onPageChange: (p: number) => void; +} + +// ── main table ─────────────────────────────────────────────────────────────── +export function ArchivedClientTable({ clients, loading, search, page, pages, onView, onPageChange }: ArchivedClientTableProps) { + return ( +
+ {/* column headers */} +
+ العميل + البريد الإلكتروني + الحالة + تاريخ الإنشاء + آخر تحديث + إجراءات +
+ + {/* loading state */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : clients.length === 0 ? ( + /* empty state */ +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."} +

+
+ ) : ( + /* data rows */ +
    + {clients.map((c, i) => ( +
  • +
    +

    {c.name}

    +

    {c.phone}

    +
    + {c.email} + + + {new Date(c.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + + + {new Date(c.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + +
    + {/* view only — delete/restore not implemented yet, matches archived users */} + onView(c)}> + + + + + +
    +
  • + ))} +
+ )} + + {/* pagination */} + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/Client/archive/ArchivedClientsModal.tsx b/src/Components/Client/archive/ArchivedClientsModal.tsx new file mode 100644 index 0000000..c198bab --- /dev/null +++ b/src/Components/Client/archive/ArchivedClientsModal.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedClientTable } from "./ArchivedClientTable"; +import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal"; +import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients"; + +interface ArchivedClientsModalProps { + onClose: () => void; +} + +export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) { + const [viewClientId, setViewClientId] = useState(null); + + const { + clients, loading, total, pages, error, + page, search, + setPage, handleSearch, clearError, + } = useArchivedClients(); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewClientId && ( + setViewClientId(null)} /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 920, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+
+

+ الأرشيف +

+

+ العملاء المؤرشفون + + ({total}) + +

+
+ +
+ + {/* body */} +
+ {/* search */} +
+ + + + 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)", + }} + /> +
+ + {error && } + + setViewClientId(client.id)} + onPageChange={setPage} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Client_Adress/Addressformmodal.tsx b/src/Components/Client_Adress/Addressformmodal.tsx index e3ad82b..ce17be7 100644 --- a/src/Components/Client_Adress/Addressformmodal.tsx +++ b/src/Components/Client_Adress/Addressformmodal.tsx @@ -106,10 +106,30 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm }, }); + type AddressFieldError = { + message?: string; + }; + type AddressErrors = { - details?: Record; - contactPerson?: Record; - location?: Record; + details?: { + street?: AddressFieldError; + city?: AddressFieldError; + state?: AddressFieldError; + district?: AddressFieldError; + buildingNo?: AddressFieldError; + unitNo?: AddressFieldError; + additionalNo?: AddressFieldError; + zipCode?: AddressFieldError; + country?: AddressFieldError; + apartment?: AddressFieldError; + }; + contactPerson?: { + name?: AddressFieldError; + phone?: AddressFieldError; + }; + location?: { + coordinates?: AddressFieldError; + }; }; const { diff --git a/src/Components/Client_Adress/archive/ArchivedClientAddressesDetailModal.tsx b/src/Components/Client_Adress/archive/ArchivedClientAddressesDetailModal.tsx new file mode 100644 index 0000000..3547037 --- /dev/null +++ b/src/Components/Client_Adress/archive/ArchivedClientAddressesDetailModal.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useEffect } from "react"; +import type { ArchivedClientAddress } from "@/src/types/client_adresses"; + +interface ArchivedClientAddressesDetailModalProps { + address: ArchivedClientAddress; + onClose: () => void; +} + +// ── small helper components ─────────────────────────────────────────────────── +function DetailRow({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function SectionHeader({ title }: { title: string }) { + return ( +

+ {title} +

+ ); +} + +// ── main component ──────────────────────────────────────────────────────────── +export function ArchivedClientAddressesDetailModal({ address, onClose }: ArchivedClientAddressesDetailModalProps) { + // close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + const { details, contactPerson, location } = address; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 80, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 480, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > + {/* ── header ── */} +
+
+

+ عنوان مؤرشف +

+

+ {address.label} +

+
+ +
+ + {/* ── body ── */} +
+ + + + + + + + + + + + + + + {(contactPerson?.name || contactPerson?.phone) && ( + <> + + + + + )} + + {location?.coordinates && ( + <> + + + + + )} + + + + + + {address.deletedAt && ( + + )} +
+ + {/* ── footer ── */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Client_Adress/archive/ArchivedClientAddressesTable.tsx b/src/Components/Client_Adress/archive/ArchivedClientAddressesTable.tsx new file mode 100644 index 0000000..0bed6a3 --- /dev/null +++ b/src/Components/Client_Adress/archive/ArchivedClientAddressesTable.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Spinner } from "../../UI"; +import type { ArchivedClientAddress } from "@/src/types/client_adresses"; + +// ── validation badge ───────────────────────────────────────────────────────── +function ValidatedBadge({ validated }: { validated: boolean }) { + return ( + + + {validated ? "موثّق" : "غير موثّق"} + + ); +} + +// ── icon button ────────────────────────────────────────────────────────────── +function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { + return ( + + ); +} + +// ── card / header styles ───────────────────────────────────────────────────── +const cardStyle: React.CSSProperties = { + borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", + background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", +}; +const thStyle: React.CSSProperties = { + padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700, + textTransform: "uppercase", letterSpacing: "0.2em", + color: "var(--color-text-muted)", background: "var(--color-surface-muted)", + borderBottom: "1px solid var(--color-border)", +}; + +// ── Props ──────────────────────────────────────────────────────────────────── +interface ArchivedClientAddressesTableProps { + addresses: ArchivedClientAddress[]; + loading: boolean; + search: string; + onView: (address: ArchivedClientAddress) => void; +} + +// ── main table — no pagination footer: the endpoint returns the full set ───── +export function ArchivedClientAddressesTable({ addresses, loading, search, onView }: ArchivedClientAddressesTableProps) { + return ( +
+ {/* column headers */} +
+ النوع + العنوان + جهة الاتصال + الحالة + تاريخ الإنشاء + إجراءات +
+ + {/* loading state */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : addresses.length === 0 ? ( + /* empty state */ +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."} +

+
+ ) : ( + /* data rows */ +
    + {addresses.map((a, i) => ( +
  • +
    +

    {a.label}

    + {a.branchName && ( +

    {a.branchName}

    + )} +
    + + {a.details.street}، {a.details.city} + + + {a.contactPerson?.name ?? "—"} + + + + {new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + +
    + {/* view only — matches archived users/clients: no delete/restore yet */} + onView(a)}> + + + + + +
    +
  • + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal.tsx b/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal.tsx new file mode 100644 index 0000000..855188a --- /dev/null +++ b/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable"; +import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal"; +import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses"; +import type { ArchivedClientAddress } from "@/src/types/client_adresses"; + +interface ArchivedClientsAddressesModalProps { + onClose: () => void; + /** Optional — scopes the archive to a single client's addresses. + * Pass the current clientId when opened from a client's addresses page; + * omit to browse the full cross-client archive. */ + clientId?: string; +} + +export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedClientsAddressesModalProps) { + const [viewAddress, setViewAddress] = useState(null); + + const { + addresses, total, loading, error, search, + handleSearch, clearError, + } = useArchivedClientAddresses(clientId); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewAddress && ( + setViewAddress(null)} /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 920, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+
+

+ الأرشيف +

+

+ العناوين المؤرشفة + + ({total}) + +

+
+ +
+ + {/* body */} +
+ {/* search — client-side only: this endpoint has no server-side search/pagination */} +
+ + + + 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)", + }} + /> +
+ + {error && } + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/car/CarDetailPanel.tsx b/src/Components/car/CarDetailPanel.tsx index 23516ae..2e75566 100644 --- a/src/Components/car/CarDetailPanel.tsx +++ b/src/Components/car/CarDetailPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { Spinner } from "../UI"; import { CarImageGallery } from "./CarImageGallery"; import { useCarDetail } from "@/src/hooks/useCars"; @@ -45,6 +46,7 @@ function DetailRow({ label, value, mono = false, warn = false }: { export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPanelProps) { const { car, loading, error } = useCarDetail(carId); const [gallery, setGallery] = useState(false); + const router = useRouter(); useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === "Escape" && !gallery) onClose(); }; @@ -220,30 +222,44 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", - display: "flex", gap: "0.75rem", + display: "flex", flexDirection: "column", gap: "0.75rem", flexShrink: 0, }}> - - - + {/* Row 1: secondary views */} +
+ + +
+ + {/* Row 2: primary edit / delete */} +
+ + +
)} diff --git a/src/Components/car/CarImageGallery.tsx b/src/Components/car/CarImageGallery.tsx index 4aac9f9..aad2c3d 100644 --- a/src/Components/car/CarImageGallery.tsx +++ b/src/Components/car/CarImageGallery.tsx @@ -19,6 +19,8 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) { const [stage, setStage] = useState("GENERAL"); const [sortBy, setSortBy] = useState<"asc" | "desc">("desc"); const [lightbox, setLightbox] = useState(null); + const [lightboxLoaded, setLightboxLoaded] = useState(false); + const [loadedIds, setLoadedIds] = useState>(new Set()); const fileRef = useRef(null); const { @@ -47,6 +49,18 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) { const imgSrc = (img: CarImage) => img.url ?? `/api/proxy/car-photos/${img.image}`; + const markLoaded = (id: string) => + setLoadedIds(prev => { + const next = new Set(prev); + next.add(id); + return next; + }); + + const openLightbox = (img: CarImage) => { + setLightbox(img); + setLightboxLoaded(false); + }; + return (
{ const stageInfo = STAGE_MAP[img.stage ?? "GENERAL"] ?? STAGE_MAP.GENERAL; const isDeleting = deleting === img.id; + const isLoaded = loadedIds.has(img.id); return (
-
setLightbox(img)}> +
openLightbox(img)}> + {!isLoaded && ( +
+ +
+ )} {/* eslint-disable-next-line @next/next/no-img-element */} {`صورة { (e.target as HTMLImageElement).src = "/file.svg"; }} + style={{ + position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", + opacity: isLoaded ? 1 : 0, + transition: "opacity 200ms", + }} + onLoad={() => markLoaded(img.id)} + onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; markLoaded(img.id); }} /> setLightbox(null)} style={{ position: "fixed", inset: 0, zIndex: 80, background: "rgba(0,0,0,0.9)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}> + {!lightboxLoaded && ( +
+ +
+ )} {/* eslint-disable-next-line @next/next/no-img-element */} عرض مكبَّر e.stopPropagation()} - onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }} + onLoad={() => setLightboxLoaded(true)} + onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; setLightboxLoaded(true); }} /> +
+ + {/* ── body ── */} +
+ + {/* loading */} + {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {/* error — fallback UI on API failure */} + {!loading && error && ( +
+ {error} +
+ )} + + {/* content */} + {!loading && role && ( +
+ + {/* icon + name + status row */} +
+ +
+

{role.name}

+

+ #{role.id} +

+
+ +
+
+
+ + {/* detail rows */} + + + +
+ )} +
+ + {/* ── footer ── */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/role/archive/ArchivedRoleTable.tsx b/src/Components/role/archive/ArchivedRoleTable.tsx new file mode 100644 index 0000000..d13e5bc --- /dev/null +++ b/src/Components/role/archive/ArchivedRoleTable.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { Spinner } from "../../UI"; +import type { ArchivedRole } from "@/src/types/role"; + +// ── status badge ───────────────────────────────────────────────────────────── +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +// ── icon button ────────────────────────────────────────────────────────────── +function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { + return ( + + ); +} + +// ── card / header styles ───────────────────────────────────────────────────── +const cardStyle: React.CSSProperties = { + borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", + background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", +}; +const thStyle: React.CSSProperties = { + padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700, + textTransform: "uppercase", letterSpacing: "0.2em", + color: "var(--color-text-muted)", background: "var(--color-surface-muted)", + borderBottom: "1px solid var(--color-border)", +}; + +// ── Props ──────────────────────────────────────────────────────────────────── +interface ArchivedRoleTableProps { + roles: ArchivedRole[]; + loading: boolean; + search: string; + page: number; + pages: number; + onView: (role: ArchivedRole) => void; + onPageChange: (p: number) => void; +} + +// ── main table ─────────────────────────────────────────────────────────────── +export function ArchivedRoleTable({ roles, loading, search, page, pages, onView, onPageChange }: ArchivedRoleTableProps) { + return ( +
+ {/* column headers */} +
+ الدور + الوصف + الحالة + تاريخ الإنشاء + آخر تحديث + إجراءات +
+ + {/* loading state */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : roles.length === 0 ? ( + /* empty state */ +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."} +

+
+ ) : ( + /* data rows */ +
    + {roles.map((r, i) => ( +
  • +

    {r.name}

    + {r.description || "—"} + + + {new Date(r.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + + + {new Date(r.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + +
    + {/* view only — delete/restore not implemented yet */} + onView(r)}> + + + + + +
    +
  • + ))} +
+ )} + + {/* pagination */} + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/role/archive/ArchivedRolesModal.tsx b/src/Components/role/archive/ArchivedRolesModal.tsx new file mode 100644 index 0000000..a4b9bb6 --- /dev/null +++ b/src/Components/role/archive/ArchivedRolesModal.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedRoleTable } from "./ArchivedRoleTable"; +import { ArchivedRoleDetailModal } from "./ArchivedRoleDetailModal"; +import { useArchivedRoles } from "@/src/hooks/archive/useArchiveRole"; + +interface ArchivedRolesModalProps { + onClose: () => void; +} + +export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) { + const [viewRoleId, setViewRoleId] = useState(null); + + const { + roles, loading, total, pages, error, + page, search, + setPage, handleSearch, clearError, + } = useArchivedRoles(); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewRoleId && ( + setViewRoleId(null)} /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 920, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+
+

+ الأرشيف +

+

+ الأدوار المؤرشفة + + ({total}) + +

+
+ +
+ + {/* body */} +
+ {/* search — client-side, since /role/archived has no search param */} +
+ + + + 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)", + }} + /> +
+ + {/* general fetch error — fallback UI */} + {error && } + + setViewRoleId(role.id)} + onPageChange={setPage} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/hooks/UseCarsMaintanance.ts b/src/hooks/UseCarsMaintanance.ts index 09584f5..464523f 100644 --- a/src/hooks/UseCarsMaintanance.ts +++ b/src/hooks/UseCarsMaintanance.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { getStoredToken } from "@/src/lib/auth"; import { carMaintenanceService } from "@/src/services/carMaintanance.service"; import type { @@ -11,10 +11,24 @@ import type { } from "../types/carMaintanance"; // ── useCarMaintenanceList ───────────────────────────────────────────────────── -// Loads every maintenance record for one car — used in the detail panel. +// Loads every maintenance record for one car — used in the detail panel/page +// and (with includeDeleted: true) to look up a single record by id without +// hitting the per-record backend endpoint. +// +// Always sorted newest-first by createdAt, per spec (defensive: sorts +// client-side even if the API already returns it in this order). +// +// By default only non-deleted (isDeleted !== true) records are returned — +// pass { includeDeleted: true } to get everything, e.g. so a detail view can +// still resolve an archived record's data. -export function useCarMaintenanceList(carId: string | null) { - const [records, setRecords] = useState([]); +export function useCarMaintenanceList( + carId: string | null, + options?: { includeDeleted?: boolean }, +) { + const includeDeleted = options?.includeDeleted ?? false; + + const [allRecords, setAllRecords] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -26,8 +40,9 @@ export function useCarMaintenanceList(carId: string | null) { carMaintenanceService .getAll(carId, token) .then((res) => { - const payload = (res as unknown as { data: { data: CarMaintenance[] } }).data ?? res; - setRecords((payload as { data: CarMaintenance[] }).data ?? []); + const list = (res as unknown as { data: CarMaintenance[] }).data ?? []; + list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + setAllRecords(list); }) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); @@ -35,9 +50,14 @@ export function useCarMaintenanceList(carId: string | null) { useEffect(() => { queueMicrotask(loadRecords); }, [loadRecords]); + const records = useMemo( + () => (includeDeleted ? allRecords : allRecords.filter((r) => !r.isDeleted)), + [allRecords, includeDeleted], + ); + // Remove a record from the list right away, instead of waiting on a refetch. const removeRecord = useCallback((id: string) => { - setRecords((prev) => prev.filter((r) => r.id !== id)); + setAllRecords((prev) => prev.filter((r) => r.id !== id)); }, []); return { records, loading, error, loadRecords, removeRecord, setError }; @@ -45,6 +65,10 @@ export function useCarMaintenanceList(carId: string | null) { // ── useCarMaintenanceDetail ─────────────────────────────────────────────────── // Fetches a single maintenance record by id. +// NOTE: relies on GET /cars/:carId/maintenance/:maintenanceId, which isn't +// wired up on the backend yet (404s). Prefer useCarMaintenanceList with +// includeDeleted: true + Array.find(id) until that route exists — see the +// maintenance detail page for the pattern. export function useCarMaintenanceDetail(carId: string | null, maintenanceId: string | null) { const [record, setRecord] = useState(null); @@ -68,6 +92,59 @@ export function useCarMaintenanceDetail(carId: string | null, maintenanceId: str return { record, loading, error }; } +// ── useCarArchivedMaintenance ───────────────────────────────────────────────── +// GET /cars/:carId/maintenance/archived — soft-deleted records for one car. +// Not auto-loaded; call `load()` when the person opens an "archive" tab/view. + +export function useCarArchivedMaintenance(carId: string | null) { + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(() => { + if (!carId) return; + const token = getStoredToken(); + setLoading(true); + setError(null); + carMaintenanceService + .getArchived(carId, token) + .then((res) => { + const list = (res as unknown as { data: CarMaintenance[] }).data ?? []; + setRecords(list); + }) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, [carId]); + + return { records, loading, error, load }; +} + +// ── useGlobalArchivedMaintenance ────────────────────────────────────────────── +// GET /maintenance/archived — every soft-deleted record, any car (admin view). +// Not auto-loaded; call `load()` when the admin page mounts / tab opens. + +export function useGlobalArchivedMaintenance() { + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(() => { + const token = getStoredToken(); + setLoading(true); + setError(null); + carMaintenanceService + .getAllArchivedGlobal(token) + .then((res) => { + const list = (res as unknown as { data: CarMaintenance[] }).data ?? []; + setRecords(list); + }) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, []); + + return { records, loading, error, load }; +} + // ── useCarMaintenanceMutations ──────────────────────────────────────────────── // Create, update, and soft-delete operations for maintenance records. @@ -98,8 +175,10 @@ export function useCarMaintenanceMutations({ setSaving(true); try { if (isNew) { + // POST /cars/:carId/maintenance — creates the record AND flips + // the car's status to "InMaintenance" on the backend. await carMaintenanceService.create(carId, payload as CreateMaintenancePayload, token); - onSuccess("تم إضافة سجل الصيانة بنجاح."); + onSuccess("تم إضافة سجل الصيانة بنجاح، وتم تحديث حالة المركبة إلى صيانة."); } else { const editTarget = getEditTarget(); if (!editTarget) return false; @@ -113,7 +192,6 @@ export function useCarMaintenanceMutations({ } return true; } catch (err: unknown) { - // Show the backend's own message when it has one, otherwise a generic fallback. onError(err instanceof Error ? err.message : "فشلت العملية."); return false; } finally { @@ -128,9 +206,12 @@ export function useCarMaintenanceMutations({ setDeleting(true); const token = getStoredToken(); try { + // DELETE /cars/:carId/maintenance/:maintenanceId — soft-deletes the + // record; backend reverts car status to "Active" and logs the + // transition in CarStatusHistory. await carMaintenanceService.delete(carId, target.id, token); onDeleted(target.id); - onSuccess("تم حذف سجل الصيانة بنجاح."); + onSuccess("تم حذف سجل الصيانة، وتم إرجاع حالة المركبة إلى نشط."); } catch (err: unknown) { onError(err instanceof Error ? err.message : "فشل الحذف."); } finally { diff --git a/src/hooks/archive/UseArchivedCarsMaintanance.ts b/src/hooks/archive/UseArchivedCarsMaintanance.ts index e69de29..2a8ab84 100644 --- a/src/hooks/archive/UseArchivedCarsMaintanance.ts +++ b/src/hooks/archive/UseArchivedCarsMaintanance.ts @@ -0,0 +1,75 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { carMaintenanceService } from "@/src/services/carMaintanance.service"; +import type { CarMaintenance } from "@/src/types/carMaintanance"; + +const PAGE_SIZE = 12; + +/** + * useArchivedCarMaintenance + * ───────────────────────────────────────────────────────────────────────── + * Same contract as `useArchivedCars`: the backend returns the whole archived + * list in one call, so pagination + search are handled client-side here. + * + * Pass a `carId` to scope to one vehicle (`GET /cars/:carId/maintenance/archived`), + * or `null` to load every archived record system-wide (`GET /maintenance/archived`). + */ +export function useArchivedCarMaintenance(carId: string | null) { + const [allRecords, setAllRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + + const load = useCallback(() => { + const token = getStoredToken(); + setLoading(true); + setError(null); + + const request = carId + ? carMaintenanceService.getArchived(carId, token) + : carMaintenanceService.getAllArchivedGlobal(token); + + request + .then((res) => { + const payload = (res as unknown as { data: { data: CarMaintenance[] } }).data ?? res; + setAllRecords((payload as { data: CarMaintenance[] }).data ?? []); + }) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, [carId]); + + useEffect(() => { queueMicrotask(load); }, [load]); + + const filtered = useMemo(() => { + if (!search.trim()) return allRecords; + const q = search.trim().toLowerCase(); + return allRecords.filter(r => + r.reason.toLowerCase().includes(q) || + (r.car + ? `${r.car.manufacturer} ${r.car.model} ${r.car.plateLetters} ${r.car.plateNumber}` + .toLowerCase() + .includes(q) + : false), + ); + }, [allRecords, search]); + + const total = filtered.length; + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const records = useMemo( + () => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + [filtered, page], + ); + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + records, loading, error, total, pages, page, search, + setPage, handleSearch, refresh: load, setError, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/Usearchivedcars.ts b/src/hooks/archive/Usearchivedcars.ts index 5ba8d1d..547906a 100644 --- a/src/hooks/archive/Usearchivedcars.ts +++ b/src/hooks/archive/Usearchivedcars.ts @@ -1,17 +1,12 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { getStoredToken } from "@/src/lib/auth"; import { carService } from "@/src/services/car.service"; import type { Car } from "@/src/types/car"; const PAGE_SIZE = 12; -// ── useArchivedCars ─────────────────────────────────────────────────────────── -// GET /cars/archived returns the full archived list at once (no page/search -// params on the backend), so pagination + search are done client-side here — -// mirrors the page/search/pages contract of useCars for a consistent UI. - export function useArchivedCars() { const [allCars, setAllCars] = useState([]); const [loading, setLoading] = useState(true); diff --git a/src/hooks/archive/useArchiveBranch.ts b/src/hooks/archive/useArchiveBranch.ts index e69de29..d7dc0c7 100644 --- a/src/hooks/archive/useArchiveBranch.ts +++ b/src/hooks/archive/useArchiveBranch.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedBranchService } from "@/src/services/archive/archivedBranch.service"; +import type { ArchivedBranch } from "@/src/types/branch"; + +export function useArchivedBranches() { + const [branches, setBranches] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [pages, setPages] = useState(1); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const token = getStoredToken(); + const res = await archivedBranchService.getAll(page, search, token); + setBranches(res.data.data); + setTotal(res.data.meta.total); + setPages(res.data.meta.totalPages); + setError(null); + } catch { + setError("تعذّر تحميل قائمة الفروع المؤرشفة. يرجى المحاولة لاحقاً."); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { queueMicrotask(load); }, [load]); + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + branches, loading, total, pages, page, search, error, + setPage, handleSearch, clearError: () => setError(null), + refresh: load, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/useArchiveClient.ts b/src/hooks/archive/useArchiveClient.ts index e69de29..fbbf635 100644 --- a/src/hooks/archive/useArchiveClient.ts +++ b/src/hooks/archive/useArchiveClient.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedClientService } from "@/src/services/archive/archivedClient.service"; +import type { ArchivedClient, ArchivedClientOrder } from "@/src/types/client"; + +// Handles a single archived client's detail data plus its archived orders. +// Kept separate from useArchivedClients.ts (list) since detail + orders +// share a clientId scope, mirroring how Archiveduserdetailmodal fetches +// its own record independently of the archived users list. +export function useArchivedClient(clientId: string) { + // ── client detail ────────────────────────────────────────────────────── + const [client, setClient] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const token = getStoredToken(); + const res = await archivedClientService.getById(clientId, token); + if (!cancelled) setClient(res.data); + } catch { + if (!cancelled) setError("تعذّر تحميل بيانات العميل المؤرشف. يرجى المحاولة لاحقاً."); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [clientId]); + + // ── archived orders (paginated, on-demand) ───────────────────────────── + const [orders, setOrders] = useState([]); + const [ordersLoading, setOrdersLoading] = useState(false); + const [ordersError, setOrdersError] = useState(null); + const [ordersPage, setOrdersPage] = useState(1); + const [ordersPages, setOrdersPages] = useState(1); + const [ordersTotal, setOrdersTotal] = useState(0); + + const loadOrders = useCallback(async () => { + setOrdersLoading(true); + try { + const token = getStoredToken(); + const res = await archivedClientService.getArchivedOrders(clientId, ordersPage, token); + setOrders(res.data.data); + setOrdersTotal(res.data.meta.total); + setOrdersPages(res.data.meta.totalPages); + setOrdersError(null); + } catch { + setOrdersError("تعذّر تحميل الطلبات المؤرشفة لهذا العميل."); + } finally { + setOrdersLoading(false); + } + }, [clientId, ordersPage]); + + useEffect(() => { queueMicrotask(loadOrders); }, [loadOrders]); + + return { + client, loading, error, + orders, ordersLoading, ordersError, + ordersPage, ordersPages, ordersTotal, + setOrdersPage, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/useArchiveClientAdresses.ts b/src/hooks/archive/useArchiveClientAdresses.ts index e69de29..3437ee8 100644 --- a/src/hooks/archive/useArchiveClientAdresses.ts +++ b/src/hooks/archive/useArchiveClientAdresses.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedClientAddressService } from "@/src/services/archive/archivedClientAdresses.service"; +import type { ArchivedClientAddress } from "@/src/types/client_adresses"; + +/** + * Loads every archived address in one shot (the API has no pagination for + * this endpoint) and applies client-side scoping/search, mirroring the + * loading/error/search contract of useArchivedUsers / useArchivedClients. + * + * @param clientId Optional — when provided, only addresses belonging to + * that client are returned. Omit to browse the full + * cross-client archive. + */ +export function useArchivedClientAddresses(clientId?: string) { + const [addresses, setAddresses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + try { + const token = getStoredToken(); + const res = await archivedClientAddressService.getAll(token); + setAddresses(res.data); + setError(null); + } catch { + setError("تعذّر تحميل أرشيف العناوين. يرجى المحاولة لاحقاً."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { queueMicrotask(load); }, [load]); + + // ── client-side scoping + search (no server support for either) ──────── + const filtered = useMemo(() => { + let list = addresses; + if (clientId) list = list.filter((a) => a.clientId === clientId); + + const q = search.trim().toLowerCase(); + if (q) { + list = list.filter( + (a) => + a.label.toLowerCase().includes(q) || + a.details.city.toLowerCase().includes(q) || + (a.branchName ?? "").toLowerCase().includes(q) || + (a.contactPerson?.name ?? "").toLowerCase().includes(q), + ); + } + return list; + }, [addresses, clientId, search]); + + return { + addresses: filtered, + total: filtered.length, + loading, + error, + search, + handleSearch: setSearch, + clearError: () => setError(null), + refresh: load, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/useArchiveRole.ts b/src/hooks/archive/useArchiveRole.ts index e69de29..4787e46 100644 --- a/src/hooks/archive/useArchiveRole.ts +++ b/src/hooks/archive/useArchiveRole.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedRoleService } from "@/src/services/archive/archivedRole.service"; +import type { ArchivedRole } from "@/src/types/role"; + +const PAGE_SIZE = 10; + +// ── normalize whatever shape the API actually returns into a flat array ────── +// Handles both a flat `data: ArchivedRole[]` (per the documented endpoint) +// and, defensively, a nested `data: { data: ArchivedRole[] }` shape in case +// the backend later adds pagination like the users/branches archive endpoints. +function extractList(res: unknown): ArchivedRole[] { + const body = res as { data?: unknown } | null | undefined; + const inner = body?.data; + + if (Array.isArray(inner)) return inner; + if (inner && typeof inner === "object" && Array.isArray((inner as { data?: unknown }).data)) { + return (inner as { data: ArchivedRole[] }).data; + } + if (Array.isArray(res)) return res as ArchivedRole[]; + + // Unexpected shape — log for debugging instead of crashing the UI. + console.error("useArchivedRoles: unexpected /role/archived response shape", res); + return []; +} + +export function useArchivedRoles() { + // full unfiltered list as returned by the API + const [allRoles, setAllRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const token = getStoredToken(); + const res = await archivedRoleService.getAll(token); + setAllRoles(extractList(res)); + setError(null); + } catch { + setError("تعذّر تحميل قائمة الأدوار المؤرشفة. يرجى المحاولة لاحقاً."); + setAllRoles([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { queueMicrotask(load); }, [load]); + + // ── client-side search (API has no search query support) ───────────────── + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + const list = Array.isArray(allRoles) ? allRoles : []; + if (!q) return list; + return list.filter(r => + r.name.toLowerCase().includes(q) || + (r.description ?? "").toLowerCase().includes(q), + ); + }, [allRoles, search]); + + // ── client-side pagination (API returns the full list in one go) ────────── + const total = filtered.length; + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const roles = useMemo( + () => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + [filtered, page], + ); + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + roles, loading, total, pages, page, search, error, + setPage, handleSearch, clearError: () => setError(null), + refresh: load, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/useArchivedClients.ts b/src/hooks/archive/useArchivedClients.ts new file mode 100644 index 0000000..d911f17 --- /dev/null +++ b/src/hooks/archive/useArchivedClients.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedClientService } from "@/src/services/archive/archivedClient.service"; +import type { ArchivedClient } from "@/src/types/client"; + +export function useArchivedClients() { + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [pages, setPages] = useState(1); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const token = getStoredToken(); + const res = await archivedClientService.getAll(page, search, token); + setClients(res.data.data); + setTotal(res.data.meta.total); + setPages(res.data.meta.totalPages); + setError(null); + } catch { + setError("تعذّر تحميل قائمة عملاء الأرشيف. يرجى المحاولة لاحقاً."); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { queueMicrotask(load); }, [load]); + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + clients, loading, total, pages, page, search, error, + setPage, handleSearch, clearError: () => setError(null), + refresh: load, + }; +} \ No newline at end of file diff --git a/src/hooks/useCars.ts b/src/hooks/useCars.ts index 185dfe6..010ed9b 100644 --- a/src/hooks/useCars.ts +++ b/src/hooks/useCars.ts @@ -53,27 +53,29 @@ export function useCars(page: number, search: string) { } // ── useCarDetail ────────────────────────────────────────────────────────────── -// Fetches a single car by ID — used in CarDetailPanel. +// Fetches a single car by ID — used in CarDetailPanel. Exposes `refetch` so +// callers can re-sync the car (e.g. its currentStatus) after a maintenance +// record is created/updated/deleted elsewhere in the tree. export function useCarDetail(carId: string) { const [car, setCar] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - queueMicrotask(() => { - const token = getStoredToken(); - setLoading(true); - setError(null); - carService - .getById(carId, token) - .then(res => setCar((res as unknown as { data: Car }).data)) - .catch((err: Error) => setError(err.message)) - .finally(() => setLoading(false)); - }); + const loadCar = useCallback(() => { + const token = getStoredToken(); + setLoading(true); + setError(null); + carService + .getById(carId, token) + .then(res => setCar((res as unknown as { data: Car }).data)) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); }, [carId]); - return { car, loading, error }; + useEffect(() => { queueMicrotask(loadCar); }, [loadCar]); + + return { car, loading, error, refetch: loadCar }; } // ── useCarMutations ─────────────────────────────────────────────────────────── diff --git a/src/hooks/useRole.ts b/src/hooks/useRole.ts index 4639f00..dd816a4 100644 --- a/src/hooks/useRole.ts +++ b/src/hooks/useRole.ts @@ -166,6 +166,7 @@ export function useRoles() { async ( id: string, data: RoleFormData, + currentPermIds: string[] ): Promise => { try { const token = getStoredToken(); diff --git a/src/services/api.ts b/src/services/api.ts index 9a18cc2..eb4b09c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -33,6 +33,11 @@ export async function request( const url = `${resolveBase()}/${path.replace(/^\/+/, "")}`; + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.debug("API request:", { url, method: init.method ?? "GET", body: init.body }); + } + const res = await fetch(url, { ...init, headers, @@ -41,9 +46,17 @@ export async function request( }); if (!res.ok) { - const json = await res.json().catch(() => null); - const message: string = - json?.message ?? json?.error ?? `HTTP ${res.status}: ${res.statusText}`; + // Try to parse JSON body for a helpful message. In dev, log the + // full response body to make debugging backend validation errors + // easier (the browser Network tab also shows this). + const text = await res.text().catch(() => null); + let json: any = null; + try { json = text ? JSON.parse(text) : null; } catch { json = null; } + const message: string = json?.message ?? json?.error ?? `HTTP ${res.status}: ${res.statusText}`; + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.error("API request failed:", { url, status: res.status, body: json ?? text }); + } throw new ApiError(res.status, message); } diff --git a/src/services/archive/archivedBranch.service.ts b/src/services/archive/archivedBranch.service.ts index e69de29..e32d788 100644 --- a/src/services/archive/archivedBranch.service.ts +++ b/src/services/archive/archivedBranch.service.ts @@ -0,0 +1,19 @@ +import { get } from "../api"; +import type { + ArchivedBranchListResponse, + ArchivedBranchResponse, +} from "@/src/types/branch"; + +/** Building a query string to fetch archived branches with pagination */ +function buildArchivedQuery(page: number, search: string): string { + return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`; +} + +export const archivedBranchService = { + /** Fetching the archived branch list, paginated */ + getAll: (page: number, search: string, token: string | null) => + get( + `branches/archived${buildArchivedQuery(page, search)}`, + token, + ), +}; \ No newline at end of file diff --git a/src/services/archive/archivedClient.service.ts b/src/services/archive/archivedClient.service.ts index e69de29..b75e03e 100644 --- a/src/services/archive/archivedClient.service.ts +++ b/src/services/archive/archivedClient.service.ts @@ -0,0 +1,34 @@ +import { get } from "../api"; +import type { + ArchivedClientListResponse, + ArchivedClientResponse, + ArchivedClientOrdersResponse, +} from "@/src/types/client"; + +/** Building a query string to fetch archived clients with pagination */ +function buildArchivedQuery(page: number, search: string): string { + return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`; +} + +export const archivedClientService = { + /** Fetching the archived client list, paginated */ + getAll: (page: number, search: string, token: string | null) => + get( + `client/archived${buildArchivedQuery(page, search)}`, + token, + ), + + /** Get a single archived client by id (includes addresses) */ + getById: (id: string, token: string | null) => + get(`client/archived/${id}`, token), + + /** Get soft-deleted orders belonging to a specific client */ + getArchivedOrders: (clientId: string, page: number, token: string | null) => + get( + `client/${clientId}/orders/archived?page=${page}&limit=10`, + token, + ), + + // NOTE: delete/restore intentionally left out for now — mirrors + // archivedUser.service.ts, see ticket follow-up. +}; \ No newline at end of file diff --git a/src/services/archive/archivedClientAdresses.service.ts b/src/services/archive/archivedClientAdresses.service.ts index e69de29..f8598f4 100644 --- a/src/services/archive/archivedClientAdresses.service.ts +++ b/src/services/archive/archivedClientAdresses.service.ts @@ -0,0 +1,16 @@ +import { get } from "../api"; +import type { ArchivedClientAddressListResponse } from "@/src/types/client_adresses"; + +export const archivedClientAddressService = { + /** + * Fetching every archived (soft-deleted) address across all clients. + * NOTE: this endpoint returns a flat array with no pagination meta, + * unlike users/archived and client/archived — so no page/limit params here. + */ + getAll: (token: string | null) => + get("addresses/archived", token), + + // NOTE: no GET /addresses/archived/{id} was specified — the list endpoint + // already returns the full address shape, so the detail modal renders + // directly from the list item instead of firing a second request. +}; \ No newline at end of file diff --git a/src/services/archive/archivedRole.service.ts b/src/services/archive/archivedRole.service.ts index e69de29..b070601 100644 --- a/src/services/archive/archivedRole.service.ts +++ b/src/services/archive/archivedRole.service.ts @@ -0,0 +1,19 @@ +import { get } from "../api"; +import type { + ArchivedRoleListResponse, + ArchivedRoleResponse, +} from "@/src/types/role"; + +export const archivedRoleService = { + /** Fetching the full archived role list — API returns a plain array, + * no server-side pagination/search, unlike users/branches archives. */ + getAll: (token: string | null) => + get("role/archived", token), + + /** Get a single archived role by id */ + getById: (id: string, token: string | null) => + get(`role/archived/${id}`, token), + + // NOTE: delete/restore intentionally left out for now — mirrors the + // archivedUser.service.ts / archivedBranch.service.ts follow-up ticket. +}; \ No newline at end of file diff --git a/src/services/carMaintanance.service.ts b/src/services/carMaintanance.service.ts index c59729e..7f63aad 100644 --- a/src/services/carMaintanance.service.ts +++ b/src/services/carMaintanance.service.ts @@ -35,6 +35,8 @@ export const carMaintenanceService = { /** * POST /cars/:carId/maintenance * Create a new maintenance record for a car. + * NOTE: this is the ONLY way a car's status can move to "InMaintenance" — + * there is no direct car-status edit anywhere in the frontend anymore. * * Example request body: * { "reason": "تغيير زيت", "cost": 150, "startAt": "2026-01-10T00:00:00.000Z" } @@ -62,17 +64,26 @@ export const carMaintenanceService = { /** * DELETE /cars/:carId/maintenance/:maintenanceId * Soft-delete a maintenance record (returns 204 No Content). + * NOTE: backend reverts the car's status to "Active" and logs the + * transition in CarStatusHistory as a side effect of this call. */ delete: (carId: string, maintenanceId: string, token: string | null) => del(`cars/${carId}/maintenance/${maintenanceId}`, token), /** * GET /cars/:carId/maintenance/archived - * Fetch soft-deleted maintenance records for a car — mirrors the car - * module's archive pattern in case the backend exposes the same route. + * Fetch soft-deleted maintenance records for a single car. */ getArchived: (carId: string, token: string | null) => get(`cars/${carId}/maintenance/archived`, token), + + /** + * GET /maintenance/archived + * Fetch every soft-deleted maintenance record system-wide (not scoped to + * one car). Used by an admin/audit view rather than the per-car panel. + */ + getAllArchivedGlobal: (token: string | null) => + get("maintenance/archived", token), }; // Re-exported so callers can build extra filters (day/month/year, search…) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index b80bed2..470cf97 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -34,13 +34,16 @@ export const clientService = { }; /** تحويل بيانات النموذج إلى payload مناسب للـ API */ -function buildPayload(data: ClientFormData): Record { - const payload: Record = { - name: data.name.trim(), - email: data.email.trim(), - phone: data.phone.trim(), - }; +function buildPayload(data: ClientFormData): Record { + const payload: Record = {}; + + if (data.name?.trim()) payload.name = data.name.trim(); + if (data.email?.trim()) payload.email = data.email.trim(); + if (data.phone?.trim()) payload.phone = data.phone.trim(); if (data.taxId?.trim()) payload.taxId = data.taxId.trim(); if (data.notes?.trim()) payload.notes = data.notes.trim(); + if (data.clientType) payload.clientType = data.clientType; + if (typeof data.isActive === "boolean") payload.isActive = data.isActive; + return payload; } \ No newline at end of file diff --git a/src/types/branch.ts b/src/types/branch.ts index cdfd005..382f3a0 100644 --- a/src/types/branch.ts +++ b/src/types/branch.ts @@ -80,4 +80,44 @@ export interface BranchDetail extends Branch { updatedAt: string; isDeleted: boolean; deletedAt: string | null; +} +// Archived branch resource returned by the archive endpoints +export interface ArchivedBranch { + id: string; + name: string; + email?: string | null; + phone?: string | null; + country: string; + city: string; + state?: string | null; + district?: string | null; + street: string; + buildingNo?: string | null; + unitNo?: string | null; + zipCode?: string | null; + latitude?: number | null; + longitude?: number | null; + isActive: boolean; + isDeleted: boolean; + createdAt: string; + updatedAt: string; +} + +// GET /branches/archived/{id} +export interface ArchivedBranchResponse { + data: ArchivedBranch; +} + +// GET /branches/archived (uses `meta`, not `pagination` — same shape as +// ArchivedUserListResponse, kept distinct rather than reused across domains) +export interface ArchivedBranchListResponse { + data: { + data: ArchivedBranch[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; + }; } \ No newline at end of file diff --git a/src/types/carMaintanance.ts b/src/types/carMaintanance.ts index 6102ff4..a355db5 100644 --- a/src/types/carMaintanance.ts +++ b/src/types/carMaintanance.ts @@ -3,22 +3,41 @@ // modules feel consistent to work with. // ── Status helpers ─────────────────────────────────────────────────────────── -// A maintenance record is "ongoing" while it has no end date yet, and -// "completed" once endAt is filled in. This is worked out on the frontend — -// the backend does not send a status field. -export type MaintenanceStatus = "Ongoing" | "Completed"; +// Worked out entirely on the frontend from endAt + isDeleted — the backend +// itself never sends a status field. +export type MaintenanceStatus = "Ongoing" | "EndingToday" | "Completed" | "Archived"; -export const MAINTENANCE_STATUS_MAP: Record< - MaintenanceStatus, - { label: string; color: string; bg: string; border: string; dot: string } -> = { - Ongoing: { label: "جارية", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" }, - Completed: { label: "منتهية", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" }, +export const MAINTENANCE_STATUS_MAP: Record = { + Ongoing: { label: "جارية", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" }, + EndingToday: { label: "تنتهي اليوم", color: "#9A3412", bg: "#FFF7ED", border: "#FED7AA", dot: "#EA580C" }, + Completed: { label: "منتهية", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" }, + Archived: { label: "مؤرشفة", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" }, }; -/** Works out whether a record is still open or already finished. */ -export function getMaintenanceStatus(record: Pick): MaintenanceStatus { - return record.endAt ? "Completed" : "Ongoing"; +/** + * Works out a record's status: + * - isDeleted → Archived (highest priority, regardless of dates) + * - no endAt → Ongoing + * - endAt before today → Completed + * - endAt is today → EndingToday + * - endAt after today → Ongoing + */ +export function getMaintenanceStatus( + record: Pick, +): MaintenanceStatus { + if (record.isDeleted) return "Archived"; + if (!record.endAt) return "Ongoing"; + + const end = new Date(record.endAt); + const today = new Date(); + + // Compare calendar dates only — ignore time-of-day. + const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()).getTime(); + const todayDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); + + if (endDay < todayDay) return "Completed"; + if (endDay === todayDay) return "EndingToday"; + return "Ongoing"; } // ── Shared date + money helpers ────────────────────────────────────────────── @@ -61,6 +80,10 @@ export interface CarMaintenance { endAt?: string | null; isActive: boolean; + /** Soft-delete flag from the backend. Records fetched from the normal + * (non-archived) endpoints should always have this false — we filter + * any stragglers out client-side in useCarMaintenanceList. */ + isDeleted?: boolean; createdAt: string; updatedAt: string; @@ -91,11 +114,7 @@ export type UpdateMaintenancePayload = Partial & { // ── API Responses ───────────────────────────────────────────────────────────── export interface MaintenanceListResponse { - data: { - data: CarMaintenance[]; - pagination?: { total: number; page: number; pages: number }; - meta?: { total: number; pages: number }; - }; + data: CarMaintenance[]; } export interface MaintenanceDetailResponse { diff --git a/src/types/client.ts b/src/types/client.ts index 5ad7598..e3a269e 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -20,6 +20,8 @@ export interface Client { name: string; email: string; phone: string; + clientType?: "Individual" | "Corporate"; + isActive?: boolean; taxId?: string; notes?: string; createdAt: string; @@ -35,11 +37,13 @@ export type UpdateClientPayload = Partial; // ─── Form & Validation ──────────────────────────────────────────────────────── export interface ClientFormData { - name: string; - email: string; - phone: string; + name?: string; + email?: string; + phone?: string; taxId?: string; notes?: string; + clientType?: "Individual" | "Corporate"; + isActive?: boolean; } export interface ClientFormErrors { @@ -87,4 +91,66 @@ export interface ApiListResponse { export interface ApiError { message: string; errors?: Record; +} +// ─── Archive: Client ──────────────────────────────────────────────────────── + +// Archived client resource returned by the archive endpoints +export interface ArchivedClient { + id: string; + name: string; + email: string; + phone: string; + taxId?: string; + notes?: string; + isActive: boolean; + isDeleted: boolean; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; + addresses?: ClientAddress[]; +} + +// GET /client/archived/{id} +export interface ArchivedClientResponse { + data: ArchivedClient; +} + +// GET /client/archived — uses `meta`, not `pagination`, matching the +// archived-users list shape rather than the live client list shape. +export interface ArchivedClientListResponse { + data: { + data: ArchivedClient[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; + }; +} + +// ─── Archive: Client orders ───────────────────────────────────────────────── + +// Archived order belonging to a specific client +export interface ArchivedClientOrder { + id: string; + orderNumber?: string; + status: string; + totalAmount?: number; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +// GET /client/{id}/orders/archived +export interface ArchivedClientOrdersResponse { + data: { + data: ArchivedClientOrder[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; + }; } \ No newline at end of file diff --git a/src/types/client_adresses.ts b/src/types/client_adresses.ts index 9d3951e..75e6610 100644 --- a/src/types/client_adresses.ts +++ b/src/types/client_adresses.ts @@ -80,4 +80,51 @@ export type AddressTableAction = | { type: "ADD"; address: ClientAddress } | { type: "UPDATE"; address: ClientAddress } | { type: "DELETE"; id: string } - | { type: "CLEAR_ERR" }; \ No newline at end of file + | { type: "CLEAR_ERR" }; + // ─── Archive: Client Addresses ───────────────────────────────────────────── + +// Archived address resource returned by GET /addresses/archived. +// Kept as its own type (not reusing ClientAddress) because the archive +// endpoint uses `_id` instead of `id` and includes the raw GeoJSON `type` +// field on `location` — mirrors how ArchivedUser is kept separate from User. +export interface ArchivedClientAddress { + _id: string; + clientId: string; + branchName: string | null; + label: string; + contactPerson?: { + name?: string; + phone?: string; + }; + details: { + country: string; + city: string; + state?: string; + district?: string; + street: string; + buildingNo?: string; + unitNo?: string; + additionalNo?: string; + zipCode?: string; + apartment?: string; + }; + location: { + type: "Point"; + coordinates: [number, number]; + }; + isValidated?: boolean; + isPrimary?: boolean; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +// GET /addresses/archived — NOTE: flat array, no pagination meta, +// unlike users/archived and client/archived. Wrapper mirrors the raw +// API envelope (success/message/responseAt) rather than ApiListResponse. +export interface ArchivedClientAddressListResponse { + success: boolean; + message: string; + responseAt: string; + data: ArchivedClientAddress[]; +} \ No newline at end of file diff --git a/src/types/role.ts b/src/types/role.ts index 1d980e1..44f3c5d 100644 --- a/src/types/role.ts +++ b/src/types/role.ts @@ -77,4 +77,26 @@ export interface ApiErrorResponse { path: string; details: ApiErrorDetail[]; }; +} +// Archived role resource returned by the archive endpoints +export interface ArchivedRole { + id: string; + name: string; + description?: string; + isActive: boolean; + isDeleted: boolean; + createdAt: string; + updatedAt: string; +} + +// GET /role/archived/{id} +export interface ArchivedRoleResponse { + data: ArchivedRole; +} + +// GET /role/archived — NOTE: unlike users/branches archive endpoints, +// this one returns a plain array with no pagination/meta wrapper. +// Search & pagination are therefore handled client-side in the hook. +export interface ArchivedRoleListResponse { + data: ArchivedRole[]; } \ No newline at end of file diff --git a/src/validations/client.validator.ts b/src/validations/client.validator.ts index 80ba58a..5e4123e 100644 --- a/src/validations/client.validator.ts +++ b/src/validations/client.validator.ts @@ -43,10 +43,26 @@ export const createClientSchema = yup.object({ .optional() .transform((val) => (val === "" ? undefined : val)), + taxId: yup + .string() + .trim() + .optional() + .transform((val) => (val === "" ? undefined : val)), + + notes: yup + .string() + .trim() + .optional() + .transform((val) => (val === "" ? undefined : val)), + clientType: yup .mixed() .oneOf([...CLIENT_TYPES] as ClientType[], "نوع العميل غير صالح") .optional(), + + isActive: yup + .boolean() + .optional(), }).required(); // ─────────────────────────────────────────────────────────────────────────────