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

This commit is contained in:
m7amedez1122
2026-07-06 17:41:12 +03:00
parent 25f3468d74
commit a7e53f2047
56 changed files with 3864 additions and 147 deletions

View File

@@ -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: "السائقون",

View File

@@ -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<Branch | null>(null);
// ID of branch whose detail modal is open; null = closed
const [viewBranchId, setViewBranchId] = useState<string | null>(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 && (
<ArchivedBranchesModal onClose={() => setArchiveOpen(false)} />
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */}
@@ -175,6 +184,9 @@ export default function BranchesPage() {
onPageChange={setPage}
/>
</section>
{/* Floating button to open the archive browser */}
<ArchiveButton onClick={() => setArchiveOpen(true)} />
</>
);
}

View File

@@ -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 (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
padding: "0.75rem 0", borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
<span style={{ fontSize: 14, fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", color: "var(--color-text-primary)" }}>
{value}
</span>
</div>
);
}
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 (
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem", maxWidth: 720, margin: "0 auto" }} dir="rtl">
<PageHeader
title="تفاصيل سجل الصيانة"
description={record?.reason}
backHref={`/dashboard/cars/${carId}/maintenance`}
backLabel="العودة"
/>
<div style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", padding: "1.5rem",
}}>
{loading && (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ التحميل</span>
</div>
)}
{error && <Alert type="error" message={error} />}
{!loading && !error && !record && (
<p style={{ fontSize: 13, color: "var(--color-text-muted)", textAlign: "center", padding: "2rem 0" }}>
لم يتم العثور على سجل الصيانة.
</p>
)}
{!loading && !error && record && (() => {
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
return (
<>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`, background: status.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: status.color }}>
{status.label}
</span>
</div>
<DetailRow label="سبب الصيانة" value={record.reason} />
<DetailRow label="التكلفة" value={fmtCost(record.cost)} />
<DetailRow label="تاريخ البدء" value={fmtDate(record.startAt)} />
<DetailRow label="تاريخ الانتهاء" value={fmtDate(record.endAt)} />
<DetailRow label="المدة" value={`${durationDays(record.startAt, record.endAt)} يوم`} />
{record.car && (
<DetailRow label="المركبة" value={`${record.car.manufacturer} ${record.car.model}${record.car.plateLetters} ${record.car.plateNumber}`} mono />
)}
<DetailRow label="تاريخ الإضافة" value={fmtDate(record.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(record.updatedAt)} />
</>
);
})()}
</div>
<div>
<button type="button" onClick={() => router.push(`/dashboard/cars/${carId}/maintenance`)}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
العودة
</button>
</div>
</section>
);
}

View File

@@ -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 (
<article style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
}}>
<div style={{ height: 4, background: status.dot }} />
<div style={{ padding: "1.25rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<p style={{ fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
{record.reason}
</p>
<span style={{
borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`,
background: status.bg, padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: status.color, whiteSpace: "nowrap", flexShrink: 0,
}}>
{status.label}
</span>
</div>
<div style={{ marginTop: "0.875rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
<div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التكلفة</span>
<span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}>{fmtCost(record.cost)}</span>
</div>
<div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>المدة</span>
<span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}>{durationDays(record.startAt, record.endAt)} يوم</span>
</div>
<div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>تاريخ البدء</span>
<span style={{ color: "var(--color-text-secondary)", marginTop: 2, display: "block" }}>{fmtDate(record.startAt)}</span>
</div>
<div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>تاريخ الانتهاء</span>
<span style={{ color: "var(--color-text-secondary)", marginTop: 2, display: "block" }}>{fmtDate(record.endAt)}</span>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="button" onClick={onView}
style={{ flex: 1, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
إظهار التفاصيل
</button>
<button type="button" onClick={onEdit}
style={{ height: 34, padding: "0 0.875rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
تعديل
</button>
<button type="button" onClick={onDelete}
style={{ height: 34, padding: "0 0.875rem", borderRadius: "var(--radius-md)", border: "1px solid #FECACA", background: "#FEF2F2", fontSize: 12, fontWeight: 600, color: "#DC2626", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
أرشفة
</button>
</div>
</div>
</article>
);
}
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<CarMaintenance | null | false>(false); // false = closed
const [deleteTarget, setDeleteTarget] = useState<CarMaintenance | null>(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 (
<>
<Toast notification={toast} />
{formTarget !== false && (
<CarMaintenanceFormModal
editRecord={formTarget}
carLabel={carLabel}
onClose={() => setFormTarget(false)}
onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) =>
handleFormSubmit(payload, isNew).then(ok => { if (ok) setFormTarget(false); return ok; })
}
/>
)}
{deleteTarget && (
<CarMaintenanceDeleteModal
record={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => handleDeleteConfirm(deleteTarget)}
/>
)}
{archiveOpen && (
<ArchivedCarsMaintenanceModal
carId={carId}
carLabel={carLabel}
onClose={() => setArchiveOpen(false)}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl">
<PageHeader
title="سجل الصيانة"
description={carLabel ?? "جارٍ تحميل بيانات المركبة…"}
backHref="/dashboard/cars"
backLabel="المركبات"
action={
<button type="button" onClick={() => setFormTarget(null)}
style={{
height: 40, padding: "0 1.125rem", borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 7,
fontFamily: "var(--font-sans)", whiteSpace: "nowrap",
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة سجل صيانة
</button>
}
/>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "5rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="md" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ تحميل سجلات الصيانة</span>
</div>
) : records.length === 0 ? (
<div style={{
borderRadius: "var(--radius-xl)", border: "2px dashed var(--color-border)",
background: "var(--color-surface)", padding: "5rem 2rem", textAlign: "center",
}}>
<div style={{ fontSize: 52, marginBottom: 16 }}>🔧</div>
<p style={{ fontSize: 16, fontWeight: 600, color: "var(--color-text-primary)" }}>لا توجد سجلات صيانة بعد</p>
<p style={{ fontSize: 13, color: "var(--color-text-muted)", marginTop: 6 }}>
اضغط على &quot;إضافة سجل صيانة&quot; لتسجيل أول عملية صيانة لهذه المركبة.
</p>
</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: "1rem" }}>
{records.map(record => (
<MaintenanceCard
key={record.id}
record={record}
onView={() => router.push(`/dashboard/cars/${carId}/maintenance/${record.id}`)}
onEdit={() => setFormTarget(record)}
onDelete={() => setDeleteTarget(record)}
/>
))}
</div>
)}
</section>
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف الصيانة" />
</>
);
}

View File

@@ -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 (
<article
onClick={onClick}
@@ -151,8 +173,37 @@ function CarCard({ car, onClick }: { car: Car; onClick: () => void }) {
</div>
</div>
{/* Send to Maintenance quick action */}
<button
type="button"
title={maintenanceDisabledReason}
disabled={maintenanceDisabled}
onClick={(e) => {
e.stopPropagation();
onSendToMaintenance(car);
}}
style={{
marginTop: "0.875rem",
width: "100%",
height: 34,
borderRadius: "var(--radius-md)",
border: `1px solid ${maintenanceDisabled ? "var(--color-border)" : "#FDE68A"}`,
background: maintenanceDisabled ? "var(--color-surface-muted)" : "#FFFBEB",
fontSize: 12, fontWeight: 700,
color: maintenanceDisabled ? "var(--color-text-hint)" : "#854D0E",
cursor: maintenanceDisabled ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
fontFamily: "var(--font-sans)",
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
إرسال للصيانة
</button>
{/* Footer CTA hint */}
<p style={{ marginTop: "0.875rem", fontSize: 11, color: "var(--color-brand-600)", fontWeight: 600, textAlign: "left" }}>
<p style={{ marginTop: "0.75rem", fontSize: 11, color: "var(--color-brand-600)", fontWeight: 600, textAlign: "left" }}>
اضغط لعرض التفاصيل
</p>
</div>
@@ -171,6 +222,7 @@ export default function CarsPage() {
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
const [archiveOpen, setArchiveOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] = useState<Car | null>(null);
const { toast, notify } = useToast();
@@ -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 && (
<CarMaintenanceFormModal
editRecord={null}
carLabel={`${maintenanceTarget.manufacturer} ${maintenanceTarget.model}${maintenanceTarget.plateLetters} ${maintenanceTarget.plateNumber}`}
onClose={() => setMaintenanceTarget(null)}
onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) =>
handleMaintenanceSubmit(payload, isNew)
}
/>
)}
{archiveOpen && (
<ArchivedCarsModal onClose={() => setArchiveOpen(false)} />
)}
@@ -357,6 +432,7 @@ export default function CarsPage() {
key={car.id}
car={car}
onClick={() => setDetailId(car.id)}
onSendToMaintenance={(c) => setMaintenanceTarget(c)}
/>
))}
</div>

View File

@@ -1,7 +0,0 @@
import React from 'react'
export default function page() {
return (
<div>page</div>
)
}

View File

@@ -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<ClientAddress | null>(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 && (
<ArchivedClientsAddressesModal
clientId={clientId}
onClose={() => setArchiveOpen(false)}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Header ── */}
@@ -655,6 +666,9 @@ export default function ClientAddressesPage() {
</div>
)}
</section>
{/* Floating button to open the address archive for this client */}
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف العناوين" />
</>
);
}

View File

@@ -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<Client | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<Client | null>(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 && (
<ArchivedClientsModal onClose={() => setArchiveOpen(false)} />
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */}
@@ -244,6 +253,9 @@ export default function ClientsPage() {
onManageAddresses={handleManageAddresses}
/>
</section>
{/* Floating button to open the archive browser */}
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف العملاء" />
</>
);
}

View File

@@ -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<Role | null>(null);
const [viewRoleId, setViewRoleId] = useState<string | null>(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 && (
<ArchivedRolesModal onClose={() => setArchiveOpen(false)} />
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Page header */}
@@ -175,6 +184,9 @@ export default function RolesPage() {
onPageChange={setPage}
/>
</section>
{/* Floating button to open the archive browser */}
<ArchiveButton onClick={() => setArchiveOpen(true)} />
</>
);
}

View File

@@ -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 (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function BranchIcon() {
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #EA580C 0%, #B91C1C 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
}}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
</svg>
</div>
);
}
// ── 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 (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-branch-detail-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 55,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
فرع مؤرشف
</p>
<h2 id="archived-branch-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{branch.name}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* icon + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<BranchIcon />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{branch.name}</p>
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
#{branch.id}
</p>
<div style={{ marginTop: 8 }}>
<StatusBadge active={branch.isActive} />
</div>
</div>
</div>
{/* detail rows — every field requested */}
<DetailRow label="رقم الهاتف" value={branch.phone} />
<DetailRow label="البريد الإلكتروني" value={branch.email} />
<DetailRow label="الدولة" value={branch.country} />
<DetailRow label="المدينة" value={branch.city} />
<DetailRow label="المنطقة" value={branch.state} />
<DetailRow label="الحي" value={branch.district} />
<DetailRow label="الشارع" value={branch.street} />
<DetailRow label="العنوان الكامل" value={fullAddress(branch)} />
<DetailRow label="رقم المبنى" value={branch.buildingNo} />
<DetailRow label="رقم الوحدة" value={branch.unitNo} />
<DetailRow label="الرمز البريدي" value={branch.zipCode} />
<DetailRow
label="الموقع الجغرافي"
value={branch.latitude != null && branch.longitude != null
? `${branch.latitude}, ${branch.longitude}`
: null}
/>
<DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
</div>
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface 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 (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
<span>الفرع</span>
<span>المدينة</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : branches.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع في الأرشيف."}
</p>
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{branches.map((b, i) => (
<li key={b.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
<StatusBadge active={b.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet */}
<IconBtn title={`عرض ${b.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(b)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<ArchivedBranch | null>(null);
const {
branches, loading, total, pages, error,
page, search,
setPage, handleSearch, clearError,
} = useArchivedBranches();
return (
<div
role="dialog" aria-modal="true" aria-labelledby="branch-archive-modal-title"
onClick={e => { 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 && (
<ArchivedBranchDetailModal branch={viewBranch} onClose={() => setViewBranch(null)} />
)}
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="branch-archive-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
الفروع المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو المدينة..."
value={search}
onChange={e => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{/* general fetch error — fallback UI */}
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedBranchTable
branches={branches}
loading={loading}
search={search}
page={page}
pages={pages}
onView={branch => setViewBranch(branch)}
onPageChange={setPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -1 +1,91 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import { fmtCost } from "@/src/types/carMaintanance";
import type { CarMaintenance } from "@/src/types/carMaintanance";
interface CarMaintenanceDeleteModalProps {
record: CarMaintenance;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function CarMaintenanceDeleteModal({
record,
deleting,
onCancel,
onConfirm,
}: CarMaintenanceDeleteModalProps) {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onCancel]);
return (
<div
role="alertdialog" aria-modal="true" aria-labelledby="maintenance-del-title"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{
position: "fixed", inset: 0, zIndex: 60,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 420,
background: "var(--color-surface)",
borderRadius: "var(--radius-xl)",
border: "1px solid #FECACA",
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
padding: "2rem",
display: "flex", flexDirection: "column", gap: "1rem",
textAlign: "center",
}}
>
{/* Icon */}
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</div>
<div>
<h2 id="maintenance-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
حذف سجل الصيانة
</h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف سجل{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{record.reason}
</strong>
{" "}(
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
{fmtCost(record.cost)}
</span>
)؟ لا يمكن التراجع عن هذا الإجراء.
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={onCancel} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
{deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -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,
});

View File

@@ -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 `<input type="number">` 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<number | undefined>(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<number | undefined>(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<CreateMaintenancePayload> = {
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<string, unknown> = { 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<string, unknown> = { reason, cost: safeCost };
if (startAt) raw.startAt = toIsoDateTime(startAt);
if (endAt) raw.endAt = toIsoDateTime(endAt);

View File

@@ -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 (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
padding: "0.6rem 0", borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
<span style={{ fontSize: 13, fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", color: "var(--color-text-primary)" }}>
{value}
</span>
</div>
);
}
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 (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-maintenance-detail-title"
onClick={e => { 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",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
سجل صيانة مؤرشف
</p>
<h2 id="archived-maintenance-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{record ? record.reason : "عرض السجل"}
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl">
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{!loading && error && (
<div style={{ padding: "1rem 1.25rem", borderRadius: "var(--radius-lg)", background: "#FEF2F2", border: "1px solid #FECACA", fontSize: 13, color: "#991B1B", fontWeight: 500, textAlign: "center" }}>
{error}
</div>
)}
{!loading && record && (() => {
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
return (
<>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`, background: status.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: status.color }}>
{status.label}
</span>
<span style={{ borderRadius: "var(--radius-full)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: "#DC2626" }}>
مؤرشف
</span>
</div>
{record.car && (
<DetailRow
label="المركبة"
value={`${record.car.manufacturer} ${record.car.model}${record.car.plateLetters} ${record.car.plateNumber}`}
mono
/>
)}
<DetailRow label="التكلفة" value={fmtCost(record.cost)} />
<DetailRow label="تاريخ البدء" value={fmtDate(record.startAt)} />
<DetailRow label="تاريخ الانتهاء" value={fmtDate(record.endAt)} />
<DetailRow label="المدة" value={`${durationDays(record.startAt, record.endAt)} يوم`} />
<DetailRow label="تاريخ الإضافة" value={fmtDate(record.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(record.updatedAt)} />
</>
);
})()}
</div>
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end" }}>
<button type="button" onClick={onClose}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
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 (
<div style={cardStyle}>
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: columns, ...thStyle }}>
<span>سبب الصيانة</span>
{showCar && <span>المركبة</span>}
<span>التكلفة</span>
<span>تاريخ البدء</span>
<span style={{ textAlign: "center" }}>تاريخ الانتهاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : records.length === 0 ? (
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
</p>
</div>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{records.map((r, i) => (
<li key={r.id} style={{
display: "grid", gridTemplateColumns: columns,
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.reason}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>أضيف {fmtDateShort(r.createdAt)}</p>
</div>
{showCar && (
<span style={{ color: "var(--color-text-secondary)" }}>
{r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"}
</span>
)}
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{fmtCost(r.cost)}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span>
<span style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-muted)" }}>
{fmtDateShort(r.endAt)}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
<IconBtn title={`عرض ${r.reason}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(r)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<CarMaintenance | null>(null);
const {
records, loading, total, pages, error,
page, search,
setPage, handleSearch, setError,
} = useArchivedCarMaintenance(carId);
return (
<div
role="dialog" aria-modal="true" aria-labelledby="archive-maintenance-modal-title"
onClick={e => { 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 && (
<ArchivedCarMaintenanceDetailPanel
record={viewRecord}
loading={false}
error={null}
onClose={() => setViewRecord(null)}
/>
)}
<div
onClick={e => 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",
}}
>
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
سجلات الصيانة المؤرشفة{carLabel ? `${carLabel}` : ""}
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بسبب الصيانة..."
value={search}
onChange={e => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
<ArchivedCarMaintenanceTable
records={records}
loading={loading}
search={search}
page={page}
pages={pages}
showCar={!carId}
onView={record => setViewRecord(record)}
onPageChange={setPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { ArchivedCarMaintenanceTable } from "./archive/ArchivedCarMaintananceTable";
export { ArchivedCarMaintenanceDetailPanel } from "./archive/ArchivedCarMaintananceDetailpanel";
export { ArchivedCarsMaintenanceModal } from "./archive/ArchivedCarsMaintananceModal";

View File

@@ -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<Record<keyof CreateClientAddressPayload, string>>;
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<CreateClientAddressPayload>({
clientId,
const [values, setValues] = useState<{
label: string;
street: string;
city: string;
state: string;
postalCode: string;
country: string;
isPrimary: boolean;
}>({
label: initialValues.label ?? "",
street: initialValues.street ?? "",
city: initialValues.city ?? "",
state: initialValues.state ?? "",
postalCode: initialValues.postalCode ?? "",
country: initialValues.country ?? "",
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<FormErrors>({});
const [submitting, setSubmitting] = useState(false);
const set =
(field: keyof Omit<CreateClientAddressPayload, "clientId" | "isPrimary">) =>
(field: "label" | "street" | "city" | "state" | "postalCode" | "country") =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
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 {

View File

@@ -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<boolean>;
}
@@ -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<CreateClientFormValues | UpdateClientFormValues>({
resolver: yupResolver(schema) as never,
} = useForm<ClientFormData>({
resolver: yupResolver(isNew ? createClientSchema : updateClientSchema) as never,
defaultValues: {
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();

View File

@@ -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 (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function Avatar({ name }: { name: string }) {
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #EA580C 0%, #B91C1C 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 22, fontWeight: 700, color: "#FFF",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
}}>
{initials}
</div>
);
}
// Small badge for an archived order row's status
function OrderStatusBadge({ status }: { status: string }) {
return (
<span style={{
fontSize: 10, fontWeight: 700, textTransform: "uppercase",
borderRadius: "var(--radius-full)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
color: "var(--color-text-muted)",
padding: "0.15rem 0.5rem",
}}>
{status}
</span>
);
}
// ── 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 (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-client-detail-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 55,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 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 ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
عميل مؤرشف
</p>
<h2 id="archived-client-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{client?.name ?? "عرض العميل"}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "75vh" }}>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && (
<div style={{
padding: "1rem 1.25rem",
borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, color: "#991B1B", fontWeight: 500,
textAlign: "center",
}}>
{error}
</div>
)}
{/* content */}
{!loading && client && (
<div dir="rtl">
{/* avatar + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<Avatar name={client.name} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{client.name}</p>
<div style={{ marginTop: 8 }}>
<StatusBadge active={client.isActive} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="البريد الإلكتروني" value={client.email} />
<DetailRow label="رقم الهاتف" value={client.phone} />
<DetailRow label="الرقم الضريبي" value={client.taxId} />
<DetailRow label="ملاحظات" value={client.notes} />
<DetailRow label="تاريخ الإنشاء" value={fmt(client.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(client.updatedAt)} />
{client.deletedAt && (
<DetailRow label="تاريخ الحذف" value={fmt(client.deletedAt)} />
)}
{/* ── addresses ── */}
<p style={{ marginTop: "1.5rem", fontSize: 11, fontWeight: 700, letterSpacing: "0.15em", textTransform: "uppercase", color: "var(--color-text-muted)" }}>
العناوين ({client.addresses?.length ?? 0})
</p>
{!client.addresses || client.addresses.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--color-text-hint)", padding: "0.75rem 0" }}>
لا توجد عناوين مرتبطة بهذا العميل.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8, paddingTop: 8 }}>
{client.addresses.map(addr => (
<div key={addr.id} style={{
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)",
padding: "0.625rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)",
}}>
<strong style={{ color: "var(--color-text-primary)" }}>{addr.label}</strong>
{" — "}{addr.street}، {addr.city}
</div>
))}
</div>
)}
{/* ── archived orders ── */}
<p style={{ marginTop: "1.5rem", fontSize: 11, fontWeight: 700, letterSpacing: "0.15em", textTransform: "uppercase", color: "var(--color-text-muted)" }}>
الطلبات المؤرشفة ({ordersTotal})
</p>
{ordersLoading ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "1rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 12 }}>جارٍ تحميل الطلبات</span>
</div>
) : ordersError ? (
<div style={{ fontSize: 12, color: "#991B1B", padding: "0.5rem 0" }}>{ordersError}</div>
) : orders.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--color-text-hint)", padding: "0.75rem 0" }}>
لا توجد طلبات مؤرشفة لهذا العميل.
</p>
) : (
<>
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 6, paddingTop: 8 }}>
{orders.map(o => (
<li key={o.id} style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)",
padding: "0.5rem 0.875rem",
fontSize: 12,
}}>
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
{o.orderNumber ?? o.id}
</span>
<OrderStatusBadge status={o.status} />
<span style={{ color: "var(--color-text-muted)" }}>
{new Date(o.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
</li>
))}
</ul>
{ordersPages > 1 && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", paddingTop: 10 }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
صفحة {ordersPage} من {ordersPages}
</span>
<div style={{ display: "flex", gap: 6 }}>
<button type="button" disabled={ordersPage === 1}
onClick={() => setOrdersPage(Math.max(1, ordersPage - 1))}
style={{ fontSize: 11, padding: "0.25rem 0.625rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", cursor: ordersPage === 1 ? "not-allowed" : "pointer", opacity: ordersPage === 1 ? 0.4 : 1 }}>
السابق
</button>
<button type="button" disabled={ordersPage === ordersPages}
onClick={() => setOrdersPage(Math.min(ordersPages, ordersPage + 1))}
style={{ fontSize: 11, padding: "0.25rem 0.625rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", cursor: ordersPage === ordersPages ? "not-allowed" : "pointer", opacity: ordersPage === ordersPages ? 0.4 : 1 }}>
التالي
</button>
</div>
</div>
)}
</>
)}
</div>
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface 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 (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
<span>العميل</span>
<span>البريد الإلكتروني</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : clients.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."}
</p>
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{clients.map((c, i) => (
<li key={c.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{c.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>
<StatusBadge active={c.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(c.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(c.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet, matches archived users */}
<IconBtn title={`عرض ${c.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(c)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string | null>(null);
const {
clients, loading, total, pages, error,
page, search,
setPage, handleSearch, clearError,
} = useArchivedClients();
return (
<div
role="dialog" aria-modal="true" aria-labelledby="archive-client-modal-title"
onClick={e => { 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 && (
<ArchivedClientDetailModal clientId={viewClientId} onClose={() => setViewClientId(null)} />
)}
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-client-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
العملاء المؤرشفون
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو البريد الإلكتروني..."
value={search}
onChange={e => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedClientTable
clients={clients}
loading={loading}
search={search}
page={page}
pages={pages}
onView={client => setViewClientId(client.id)}
onPageChange={setPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -106,10 +106,30 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
},
});
type AddressFieldError = {
message?: string;
};
type AddressErrors = {
details?: Record<string, unknown>;
contactPerson?: Record<string, unknown>;
location?: Record<string, unknown>;
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 {

View File

@@ -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 (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--color-text-primary)" }}>
{value}
</span>
</div>
);
}
function SectionHeader({ title }: { title: string }) {
return (
<p style={{
fontSize: 11, fontWeight: 700, color: "var(--color-text-muted)",
letterSpacing: "0.08em", textTransform: "uppercase",
margin: "1.25rem 0 0", paddingBottom: "0.25rem",
borderBottom: "1px solid var(--color-border)",
}}>
{title}
</p>
);
}
// ── 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 (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-address-detail-title"
onClick={e => { 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",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
عنوان مؤرشف
</p>
<h2 id="archived-address-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{address.label}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl">
<DetailRow label="معرف العميل" value={address.clientId} />
<DetailRow label="الفرع" value={address.branchName} />
<SectionHeader title="تفاصيل العنوان" />
<DetailRow label="الشارع" value={details.street} />
<DetailRow label="المدينة" value={details.city} />
<DetailRow label="المنطقة" value={details.state} />
<DetailRow label="الحي" value={details.district} />
<DetailRow label="رقم المبنى" value={details.buildingNo} />
<DetailRow label="رقم الوحدة" value={details.unitNo} />
<DetailRow label="الرقم الإضافي" value={details.additionalNo} />
<DetailRow label="الرمز البريدي" value={details.zipCode} />
<DetailRow label="الدولة" value={details.country} />
{(contactPerson?.name || contactPerson?.phone) && (
<>
<SectionHeader title="جهة الاتصال" />
<DetailRow label="الاسم" value={contactPerson.name} />
<DetailRow label="رقم الهاتف" value={contactPerson.phone} />
</>
)}
{location?.coordinates && (
<>
<SectionHeader title="الإحداثيات الجغرافية" />
<DetailRow label="خط الطول (Longitude)" value={String(location.coordinates[0])} />
<DetailRow label="خط العرض (Latitude)" value={String(location.coordinates[1])} />
</>
)}
<SectionHeader title="معلومات النظام" />
<DetailRow label="حالة التوثيق" value={address.isValidated ? "موثّق" : "غير موثّق"} />
<DetailRow label="تاريخ الإنشاء" value={new Date(address.createdAt).toLocaleString("ar-SA")} />
<DetailRow label="آخر تحديث" value={new Date(address.updatedAt).toLocaleString("ar-SA")} />
{address.deletedAt && (
<DetailRow label="تاريخ الحذف" value={new Date(address.deletedAt).toLocaleString("ar-SA")} />
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: validated ? "1px solid #BBF7D0" : "1px solid var(--color-border)", background: validated ? "#DCFCE7" : "var(--color-surface-muted)", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: validated ? "#166534" : "var(--color-text-muted)" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: validated ? "#16A34A" : "var(--color-text-hint)" }} />
{validated ? "موثّق" : "غير موثّق"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface 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 (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px", ...thStyle }}>
<span>النوع</span>
<span>العنوان</span>
<span>جهة الاتصال</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : addresses.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
</p>
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{addresses.map((a, i) => (
<li key={a._id} style={{
display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p>
{a.branchName && (
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p>
)}
</div>
<span style={{ color: "var(--color-text-secondary)" }}>
{a.details.street}، {a.details.city}
</span>
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
{a.contactPerson?.name ?? "—"}
</span>
<ValidatedBadge validated={!!a.isValidated} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — matches archived users/clients: no delete/restore yet */}
<IconBtn title={`عرض ${a.label}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(a)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -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<ArchivedClientAddress | null>(null);
const {
addresses, total, loading, error, search,
handleSearch, clearError,
} = useArchivedClientAddresses(clientId);
return (
<div
role="dialog" aria-modal="true" aria-labelledby="archive-address-modal-title"
onClick={e => { 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 && (
<ArchivedClientAddressesDetailModal address={viewAddress} onClose={() => setViewAddress(null)} />
)}
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-address-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
العناوين المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search — client-side only: this endpoint has no server-side search/pagination */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالنوع أو المدينة أو الفرع..."
value={search}
onChange={e => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedClientAddressesTable
addresses={addresses}
loading={loading}
search={search}
onView={setViewAddress}
/>
</div>
</div>
</div>
);
}

View File

@@ -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,9 +222,11 @@ 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 */}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button type="button" onClick={() => setGallery(true)}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -232,6 +236,17 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
</svg>
الصور
</button>
<button type="button" onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
عرض تاريخ الصيانة
</button>
</div>
{/* Row 2: primary edit / delete */}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button type="button" onClick={() => onEdit(car)}
style={{ flex: 2, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
@@ -245,6 +260,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
حذف
</button>
</div>
</div>
)}
</aside>

View File

@@ -19,6 +19,8 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
const [stage, setStage] = useState<ImageStage>("GENERAL");
const [sortBy, setSortBy] = useState<"asc" | "desc">("desc");
const [lightbox, setLightbox] = useState<CarImage | null>(null);
const [lightboxLoaded, setLightboxLoaded] = useState(false);
const [loadedIds, setLoadedIds] = useState<Set<string>>(new Set());
const fileRef = useRef<HTMLInputElement>(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 (
<div
role="dialog" aria-modal="true" aria-label="معرض صور المركبة"
@@ -141,6 +155,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
{images.map(img => {
const stageInfo = STAGE_MAP[img.stage ?? "GENERAL"] ?? STAGE_MAP.GENERAL;
const isDeleting = deleting === img.id;
const isLoaded = loadedIds.has(img.id);
return (
<div key={img.id} style={{
borderRadius: "var(--radius-xl)",
@@ -151,13 +166,27 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
opacity: isDeleting ? 0.5 : 1,
transition: "opacity 200ms",
}}>
<div style={{ position: "relative", paddingBottom: "70%", cursor: "pointer" }} onClick={() => setLightbox(img)}>
<div style={{ position: "relative", paddingBottom: "70%", cursor: "pointer", background: "var(--color-surface-muted)" }} onClick={() => openLightbox(img)}>
{!isLoaded && (
<div style={{
position: "absolute", inset: 0,
display: "flex", alignItems: "center", justifyContent: "center",
background: "var(--color-surface-muted)",
}}>
<Spinner size="sm" className="text-blue-600" />
</div>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc(img)}
alt={`صورة ${stageInfo.label}`}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }}
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); }}
/>
<span style={{
position: "absolute", top: 8, right: 8,
@@ -196,13 +225,23 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
{/* ── Lightbox ── */}
{lightbox && (
<div onClick={() => setLightbox(null)} style={{ position: "fixed", inset: 0, zIndex: 80, background: "rgba(0,0,0,0.9)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}>
{!lightboxLoaded && (
<div style={{ position: "absolute", display: "flex", alignItems: "center", justifyContent: "center" }}>
<Spinner size="lg" className="text-white" />
</div>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc(lightbox)}
alt="عرض مكبَّر"
style={{ maxWidth: "90vw", maxHeight: "90vh", borderRadius: "var(--radius-lg)", objectFit: "contain" }}
style={{
maxWidth: "90vw", maxHeight: "90vh", borderRadius: "var(--radius-lg)", objectFit: "contain",
opacity: lightboxLoaded ? 1 : 0,
transition: "opacity 200ms",
}}
onClick={e => 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); }}
/>
<button onClick={() => setLightbox(null)} style={{ position: "absolute", top: 24, right: 24, width: 40, height: 40, borderRadius: "50%", background: "rgba(255,255,255,0.15)", border: "none", color: "#fff", fontSize: 20, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
×

View File

@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState } from "react";
import { Spinner } from "../../UI";
import { getStoredToken } from "@/src/lib/auth";
import { archivedRoleService } from "@/src/services/archive/archivedRole.service";
import type { ArchivedRole } from "@/src/types/role";
interface ArchivedRoleDetailModalProps {
roleId: string;
onClose: () => void;
}
// ── small helper components ───────────────────────────────────────────────────
function DetailRow({ label, value }: { label: string; value?: string | null }) {
return (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function RoleIcon() {
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #EA580C 0%, #B91C1C 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
}}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
<path d="M12 2 3 7v6c0 5 4 9 9 9s9-4 9-9V7l-9-5z" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailModalProps) {
const [role, setRole] = useState<ArchivedRole | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// fetch archived role details on mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const token = getStoredToken();
const res = await archivedRoleService.getById(roleId, token);
if (!cancelled) setRole(res.data);
} catch {
if (!cancelled) setError("تعذّر تحميل بيانات الدور المؤرشف. يرجى المحاولة لاحقاً.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [roleId]);
// ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) =>
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
// ── render ────────────────────────────────────────────────────────────────
return (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-role-detail-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 55,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
دور مؤرشف
</p>
<h2 id="archived-role-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{role?.name ?? "عرض الدور"}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error — fallback UI on API failure */}
{!loading && error && (
<div style={{
padding: "1rem 1.25rem",
borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, color: "#991B1B", fontWeight: 500,
textAlign: "center",
}}>
{error}
</div>
)}
{/* content */}
{!loading && role && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* icon + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<RoleIcon />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
#{role.id}
</p>
<div style={{ marginTop: 8 }}>
<StatusBadge active={role.isActive} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="الوصف" value={role.description} />
<DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />
</div>
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface 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 (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 2fr 1fr 1fr 1fr 100px", ...thStyle }}>
<span>الدور</span>
<span>الوصف</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : roles.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
</p>
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{roles.map((r, i) => (
<li key={r.id} style={{
display: "grid", gridTemplateColumns: "2fr 2fr 1fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.name}</p>
<span style={{ color: "var(--color-text-secondary)" }}>{r.description || "—"}</span>
<StatusBadge active={r.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(r.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(r.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet */}
<IconBtn title={`عرض ${r.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(r)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string | null>(null);
const {
roles, loading, total, pages, error,
page, search,
setPage, handleSearch, clearError,
} = useArchivedRoles();
return (
<div
role="dialog" aria-modal="true" aria-labelledby="role-archive-modal-title"
onClick={e => { 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 && (
<ArchivedRoleDetailModal roleId={viewRoleId} onClose={() => setViewRoleId(null)} />
)}
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="role-archive-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
الأدوار المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search — client-side, since /role/archived has no search param */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث باسم الدور..."
value={search}
onChange={e => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40,
paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div>
{/* general fetch error — fallback UI */}
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedRoleTable
roles={roles}
loading={loading}
search={search}
page={page}
pages={pages}
onView={role => setViewRoleId(role.id)}
onPageChange={setPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -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<CarMaintenance[]>([]);
export function useCarMaintenanceList(
carId: string | null,
options?: { includeDeleted?: boolean },
) {
const includeDeleted = options?.includeDeleted ?? false;
const [allRecords, setAllRecords] = useState<CarMaintenance[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<CarMaintenance | null>(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<CarMaintenance[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<CarMaintenance[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 {

View File

@@ -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<CarMaintenance[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}

View File

@@ -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<Car[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -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<ArchivedBranch[]>([]);
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<string | null>(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,
};
}

View File

@@ -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<ArchivedClient | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<ArchivedClientOrder[]>([]);
const [ordersLoading, setOrdersLoading] = useState(false);
const [ordersError, setOrdersError] = useState<string | null>(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,
};
}

View File

@@ -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<ArchivedClientAddress[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}

View File

@@ -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<ArchivedRole[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(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,
};
}

View File

@@ -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<ArchivedClient[]>([]);
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<string | null>(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,
};
}

View File

@@ -53,15 +53,16 @@ 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<Car | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
queueMicrotask(() => {
const loadCar = useCallback(() => {
const token = getStoredToken();
setLoading(true);
setError(null);
@@ -70,10 +71,11 @@ export function useCarDetail(carId: string) {
.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 ───────────────────────────────────────────────────────────

View File

@@ -166,6 +166,7 @@ export function useRoles() {
async (
id: string,
data: RoleFormData,
currentPermIds: string[]
): Promise<boolean> => {
try {
const token = getStoredToken();

View File

@@ -33,6 +33,11 @@ export async function request<T>(
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<T>(
});
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);
}

View File

@@ -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<ArchivedBranchListResponse>(
`branches/archived${buildArchivedQuery(page, search)}`,
token,
),
};

View File

@@ -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<ArchivedClientListResponse>(
`client/archived${buildArchivedQuery(page, search)}`,
token,
),
/** Get a single archived client by id (includes addresses) */
getById: (id: string, token: string | null) =>
get<ArchivedClientResponse>(`client/archived/${id}`, token),
/** Get soft-deleted orders belonging to a specific client */
getArchivedOrders: (clientId: string, page: number, token: string | null) =>
get<ArchivedClientOrdersResponse>(
`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.
};

View File

@@ -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<ArchivedClientAddressListResponse>("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.
};

View File

@@ -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<ArchivedRoleListResponse>("role/archived", token),
/** Get a single archived role by id */
getById: (id: string, token: string | null) =>
get<ArchivedRoleResponse>(`role/archived/${id}`, token),
// NOTE: delete/restore intentionally left out for now — mirrors the
// archivedUser.service.ts / archivedBranch.service.ts follow-up ticket.
};

View File

@@ -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<void>(`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<MaintenanceListResponse>(`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<MaintenanceListResponse>("maintenance/archived", token),
};
// Re-exported so callers can build extra filters (day/month/year, search…)

View File

@@ -34,13 +34,16 @@ export const clientService = {
};
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
function buildPayload(data: ClientFormData): Record<string, string> {
const payload: Record<string, string> = {
name: data.name.trim(),
email: data.email.trim(),
phone: data.phone.trim(),
};
function buildPayload(data: ClientFormData): Record<string, string | boolean> {
const payload: Record<string, string | boolean> = {};
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;
}

View File

@@ -81,3 +81,43 @@ export interface BranchDetail extends Branch {
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;
};
};
}

View File

@@ -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 }
> = {
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" },
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<CarMaintenance, "endAt">): 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<CarMaintenance, "endAt" | "isDeleted">,
): 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<CreateMaintenancePayload> & {
// ── API Responses ─────────────────────────────────────────────────────────────
export interface MaintenanceListResponse {
data: {
data: CarMaintenance[];
pagination?: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
export interface MaintenanceDetailResponse {

View File

@@ -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<CreateClientPayload>;
// ─── 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 {
@@ -88,3 +92,65 @@ export interface ApiError {
message: string;
errors?: Record<string, string[]>;
}
// ─── 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;
};
};
}

View File

@@ -81,3 +81,50 @@ export type AddressTableAction =
| { type: "UPDATE"; address: ClientAddress }
| { type: "DELETE"; id: string }
| { 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<T>.
export interface ArchivedClientAddressListResponse {
success: boolean;
message: string;
responseAt: string;
data: ArchivedClientAddress[];
}

View File

@@ -78,3 +78,25 @@ export interface ApiErrorResponse {
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[];
}

View File

@@ -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<ClientType>()
.oneOf([...CLIENT_TYPES] as ClientType[], "نوع العميل غير صالح")
.optional(),
isActive: yup
.boolean()
.optional(),
}).required();
// ─────────────────────────────────────────────────────────────────────────────