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:
@@ -57,12 +57,6 @@ const navSections = [
|
|||||||
icon: "ti-car",
|
icon: "ti-car",
|
||||||
permission: "read-car",
|
permission: "read-car",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/dashboard/cars_maintenance",
|
|
||||||
label: "صيانة السيارات",
|
|
||||||
icon: "ti-tool",
|
|
||||||
permission: "read-maintenance",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: "/dashboard/drivers",
|
href: "/dashboard/drivers",
|
||||||
label: "السائقون",
|
label: "السائقون",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { BranchTable } from "@/src/Components/Branch/BranchTable";
|
||||||
import { BranchFormModal } from "@/src/Components/Branch/BranchFormModal";
|
import { BranchFormModal } from "@/src/Components/Branch/BranchFormModal";
|
||||||
import { BranchDetailModal } from "@/src/Components/Branch/BranchDetailModal";
|
import { BranchDetailModal } from "@/src/Components/Branch/BranchDetailModal";
|
||||||
import { DeleteConfirmModal } from "@/src/Components/Branch/DeleteConfirmModal";
|
import { DeleteConfirmModal } from "@/src/Components/Branch/DeleteConfirmModal";
|
||||||
import { useBranches } from "@/src/hooks/useBranch";
|
import { useBranches } from "@/src/hooks/useBranch";
|
||||||
import type { Branch, BranchFormData } from "@/src/types/branch";
|
import type { Branch, BranchFormData } from "@/src/types/branch";
|
||||||
|
import { ArchivedBranchesModal } from "@/src/Components/Branch/archive/ArchivedBranchesModal";
|
||||||
|
|
||||||
|
|
||||||
export default function BranchesPage() {
|
export default function BranchesPage() {
|
||||||
// ── Modal state ─────────────────────────────────────────────────────────────
|
// ── Modal state ─────────────────────────────────────────────────────────────
|
||||||
@@ -16,6 +18,8 @@ export default function BranchesPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
|
||||||
// ID of branch whose detail modal is open; null = closed
|
// ID of branch whose detail modal is open; null = closed
|
||||||
const [viewBranchId, setViewBranchId] = useState<string | null>(null);
|
const [viewBranchId, setViewBranchId] = useState<string | null>(null);
|
||||||
|
// Archive browser modal open/closed
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
// Local submitting flag shown in DeleteConfirmModal spinner
|
// Local submitting flag shown in DeleteConfirmModal spinner
|
||||||
const [deleting, setDeleting] = useState(false);
|
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" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* ── Page header ── */}
|
{/* ── Page header ── */}
|
||||||
@@ -175,6 +184,9 @@ export default function BranchesPage() {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Floating button to open the archive browser */}
|
||||||
|
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
101
app/dashboard/cars/[carId]/maintenance/[maintenanceId]/page.tsx
Normal file
101
app/dashboard/cars/[carId]/maintenance/[maintenanceId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
app/dashboard/cars/[carId]/maintenance/page.tsx
Normal file
214
app/dashboard/cars/[carId]/maintenance/page.tsx
Normal 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 }}>
|
||||||
|
اضغط على "إضافة سجل صيانة" لتسجيل أول عملية صيانة لهذه المركبة.
|
||||||
|
</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="أرشيف الصيانة" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,12 @@ import { CarFormModal } from "@/src/Components/car/CarFormModal";
|
|||||||
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
|
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
|
||||||
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
|
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
|
||||||
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
|
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 { 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 { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
|
||||||
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
|
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
|
||||||
|
import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -41,11 +44,30 @@ function CarToast({ notification }: { notification: ToastMsg | null }) {
|
|||||||
|
|
||||||
// ── CarCard ───────────────────────────────────────────────────────────────────
|
// ── 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 status = STATUS_MAP[car.currentStatus];
|
||||||
const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null;
|
const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null;
|
||||||
const regWarn = isExpiringSoon(car.registrationExpiryDate);
|
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 (
|
return (
|
||||||
<article
|
<article
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -151,8 +173,37 @@ function CarCard({ car, onClick }: { car: Car; onClick: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,6 +222,7 @@ export default function CarsPage() {
|
|||||||
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
|
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
const [maintenanceTarget, setMaintenanceTarget] = useState<Car | null>(null);
|
||||||
|
|
||||||
const { toast, notify } = useToast();
|
const { toast, notify } = useToast();
|
||||||
|
|
||||||
@@ -194,6 +246,18 @@ export default function CarsPage() {
|
|||||||
await handleDeleteConfirm(deleteTarget);
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
return (
|
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 && (
|
{archiveOpen && (
|
||||||
<ArchivedCarsModal onClose={() => setArchiveOpen(false)} />
|
<ArchivedCarsModal onClose={() => setArchiveOpen(false)} />
|
||||||
)}
|
)}
|
||||||
@@ -357,6 +432,7 @@ export default function CarsPage() {
|
|||||||
key={car.id}
|
key={car.id}
|
||||||
car={car}
|
car={car}
|
||||||
onClick={() => setDetailId(car.id)}
|
onClick={() => setDetailId(car.id)}
|
||||||
|
onSendToMaintenance={(c) => setMaintenanceTarget(c)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
return (
|
|
||||||
<div>page</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
||||||
import { clientService } from "@/src/services/client.service";
|
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 type { ClientAddress } from "@/src/types/client_adresses";
|
||||||
|
|
||||||
import { AddressFormModal, DeleteConfirmModal, ClientFormModal } from "@/src/Components/Client";
|
import { AddressFormModal, DeleteConfirmModal, ClientFormModal } from "@/src/Components/Client";
|
||||||
|
import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal";
|
||||||
import type {
|
import type {
|
||||||
CreateAddressFormValues,
|
CreateAddressFormValues,
|
||||||
UpdateAddressFormValues,
|
UpdateAddressFormValues,
|
||||||
@@ -352,6 +353,8 @@ export default function ClientAddressesPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [editingClient, setEditingClient] = 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 ─────────────────────────────────────────────────
|
// ── Address form handler ─────────────────────────────────────────────────
|
||||||
const handleAddressSubmit = async (
|
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" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
@@ -655,6 +666,9 @@ export default function ClientAddressesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Floating button to open the address archive for this client */}
|
||||||
|
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف العناوين" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
// ── UI components ──────────────────────────────────────────────────────────
|
// ── UI components ──────────────────────────────────────────────────────────
|
||||||
// NOTE: Toast is now imported from the canonical UI barrel, NOT from Client/Toast.
|
// 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 ─────────────────────────────────────────────
|
// ── Client-specific components ─────────────────────────────────────────────
|
||||||
import { useClients } from "@/src/hooks/useClients";
|
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 { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
|
||||||
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
||||||
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
||||||
|
import { ArchivedClientsModal } from "@/src/Components/Client/archive/ArchivedClientsModal";
|
||||||
|
|
||||||
|
|
||||||
export default function ClientsPage() {
|
export default function ClientsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -23,6 +25,8 @@ export default function ClientsPage() {
|
|||||||
const [formTarget, setFormTarget] = useState<Client | null | false>(false);
|
const [formTarget, setFormTarget] = useState<Client | null | false>(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Client | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Client | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
// Archive browser modal open/closed
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
|
||||||
// ── Data hook ────────────────────────────────────────────────────────────
|
// ── Data hook ────────────────────────────────────────────────────────────
|
||||||
const {
|
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" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* ── Page header ── */}
|
{/* ── Page header ── */}
|
||||||
@@ -244,6 +253,9 @@ export default function ClientsPage() {
|
|||||||
onManageAddresses={handleManageAddresses}
|
onManageAddresses={handleManageAddresses}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Floating button to open the archive browser */}
|
||||||
|
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف العملاء" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { RoleTable } from "@/src/Components/role/RoleTable";
|
||||||
import { RoleFormModal } from "@/src/Components/role/RoleFormModal";
|
import { RoleFormModal } from "@/src/Components/role/RoleFormModal";
|
||||||
import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal";
|
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 { Toast } from "@/src/Components/UI";
|
||||||
import { useRoles } from "@/src/hooks/useRole";
|
import { useRoles } from "@/src/hooks/useRole";
|
||||||
import { Role, RoleFormData } from "@/src/types/role";
|
import { Role, RoleFormData } from "@/src/types/role";
|
||||||
|
import { ArchivedRolesModal } from "@/src/Components/role/archive/ArchivedRolesModal";
|
||||||
|
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
// ── Modal state ──────────────────────────────────────────────────────────────
|
// ── Modal state ──────────────────────────────────────────────────────────────
|
||||||
@@ -17,6 +19,8 @@ export default function RolesPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null);
|
||||||
const [viewRoleId, setViewRoleId] = useState<string | null>(null);
|
const [viewRoleId, setViewRoleId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
// Archive browser modal open/closed
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
|
||||||
// ── Data hook ────────────────────────────────────────────────────────────────
|
// ── Data hook ────────────────────────────────────────────────────────────────
|
||||||
const {
|
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" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
@@ -175,6 +184,9 @@ export default function RolesPage() {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Floating button to open the archive browser */}
|
||||||
|
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
206
src/Components/Branch/archive/ArchivedBranchDetailModal.tsx
Normal file
206
src/Components/Branch/archive/ArchivedBranchDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/Components/Branch/archive/ArchivedBranchTable.tsx
Normal file
134
src/Components/Branch/archive/ArchivedBranchTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/Components/Branch/archive/ArchivedBranchesModal.tsx
Normal file
117
src/Components/Branch/archive/ArchivedBranchesModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,6 @@ import type {
|
|||||||
} from "@/src/types/carMaintanance";
|
} from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
// ── 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 }) {
|
function MaintenanceToast({ notification }: { notification: { type: "success" | "error"; message: string } | null }) {
|
||||||
if (!notification) return null;
|
if (!notification) return null;
|
||||||
@@ -127,11 +126,16 @@ interface CarMaintenanceDetailPanelProps {
|
|||||||
/** e.g. "تويوتا لاند كروزر — أ ب ج 1234", shown in the header. */
|
/** e.g. "تويوتا لاند كروزر — أ ب ج 1234", shown in the header. */
|
||||||
carLabel?: string;
|
carLabel?: string;
|
||||||
onClose: () => void;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── 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 { records, loading, error, loadRecords, removeRecord } = useCarMaintenanceList(carId);
|
||||||
const { toast, notify } = useMaintenanceToast();
|
const { toast, notify } = useMaintenanceToast();
|
||||||
|
|
||||||
@@ -145,9 +149,9 @@ export function CarMaintenanceDetailPanel({ carId, carLabel, onClose }: CarMaint
|
|||||||
|
|
||||||
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMaintenanceMutations({
|
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMaintenanceMutations({
|
||||||
carId,
|
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 }),
|
onError: (msg) => notify({ type: "error", message: msg }),
|
||||||
onDeleted: (id) => { removeRecord(id); setDeleteTarget(null); },
|
onDeleted: (id) => { removeRecord(id); setDeleteTarget(null); onCarStatusChanged?.(); },
|
||||||
getEditTarget,
|
getEditTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ const errorTextStyle: React.CSSProperties = {
|
|||||||
fontWeight: 500,
|
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 ────────────────────────────────────────────────────────────
|
// ── yup validation ────────────────────────────────────────────────────────────
|
||||||
// Checks the form values against the right schema and turns any problems
|
// Checks the form values against the right schema and turns any problems
|
||||||
// into a simple field -> message map the inputs below can read from.
|
// into a simple field -> message map the inputs below can read from.
|
||||||
@@ -103,7 +118,9 @@ export function CarMaintenanceFormModal({
|
|||||||
|
|
||||||
// ── Form state ─────────────────────────────────────────────────────────────
|
// ── Form state ─────────────────────────────────────────────────────────────
|
||||||
const [reason, setReason] = useState(editRecord?.reason ?? "");
|
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 [startAt, setStartAt] = useState(editRecord?.startAt?.slice(0, 10) ?? "");
|
||||||
const [endAt, setEndAt] = useState(editRecord?.endAt?.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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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.
|
// Build a snapshot for yup — include everything so optional rules also run.
|
||||||
const formSnapshot: Partial<CreateMaintenancePayload> = {
|
const formSnapshot: Partial<CreateMaintenancePayload> = {
|
||||||
reason,
|
reason,
|
||||||
cost,
|
cost: safeCost,
|
||||||
...(startAt && { startAt }),
|
...(startAt && { startAt }),
|
||||||
...(endAt && { endAt }),
|
...(endAt && { endAt }),
|
||||||
};
|
};
|
||||||
@@ -149,8 +171,9 @@ export function CarMaintenanceFormModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the final payload — dates go out as full ISO-8601 strings.
|
// Build the final payload — dates go out as full ISO-8601 strings, and
|
||||||
const raw: Record<string, unknown> = { reason, cost };
|
// 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 (startAt) raw.startAt = toIsoDateTime(startAt);
|
||||||
if (endAt) raw.endAt = toIsoDateTime(endAt);
|
if (endAt) raw.endAt = toIsoDateTime(endAt);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { ArchivedCarMaintenanceTable } from "./archive/ArchivedCarMaintananceTable";
|
||||||
|
export { ArchivedCarMaintenanceDetailPanel } from "./archive/ArchivedCarMaintananceDetailpanel";
|
||||||
|
export { ArchivedCarsMaintenanceModal } from "./archive/ArchivedCarsMaintananceModal";
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, FormEvent } from "react";
|
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";
|
import { Input, Select, Button } from "../UI";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -12,11 +12,18 @@ interface ClientAddressFormProps {
|
|||||||
submitLabel?: string;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── 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 = {};
|
const e: FormErrors = {};
|
||||||
if (!v.label.trim()) e.label = "Label is required.";
|
if (!v.label.trim()) e.label = "Label is required.";
|
||||||
if (!v.street.trim()) e.street = "Street is required.";
|
if (!v.street.trim()) e.street = "Street is required.";
|
||||||
@@ -39,21 +46,28 @@ export default function ClientAddressForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
submitLabel = "Save address",
|
submitLabel = "Save address",
|
||||||
}: ClientAddressFormProps) {
|
}: ClientAddressFormProps) {
|
||||||
const [values, setValues] = useState<CreateClientAddressPayload>({
|
const [values, setValues] = useState<{
|
||||||
clientId,
|
label: string;
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}>({
|
||||||
label: initialValues.label ?? "",
|
label: initialValues.label ?? "",
|
||||||
street: initialValues.street ?? "",
|
street: initialValues.details?.street ?? "",
|
||||||
city: initialValues.city ?? "",
|
city: initialValues.details?.city ?? "",
|
||||||
state: initialValues.state ?? "",
|
state: initialValues.details?.state ?? "",
|
||||||
postalCode: initialValues.postalCode ?? "",
|
postalCode: initialValues.details?.zipCode ?? "",
|
||||||
country: initialValues.country ?? "",
|
country: initialValues.details?.country ?? "",
|
||||||
isPrimary: initialValues.isPrimary ?? false,
|
isPrimary: initialValues.isPrimary ?? false,
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const set =
|
const set =
|
||||||
(field: keyof Omit<CreateClientAddressPayload, "clientId" | "isPrimary">) =>
|
(field: "label" | "street" | "city" | "state" | "postalCode" | "country") =>
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
setValues((v) => ({ ...v, [field]: e.target.value }));
|
setValues((v) => ({ ...v, [field]: e.target.value }));
|
||||||
if (errors[field]) setErrors((er) => ({ ...er, [field]: undefined }));
|
if (errors[field]) setErrors((er) => ({ ...er, [field]: undefined }));
|
||||||
@@ -68,7 +82,25 @@ export default function ClientAddressForm({
|
|||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
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 {
|
} catch {
|
||||||
/* parent surfaces the error */
|
/* parent surfaces the error */
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import {
|
|||||||
createClientSchema,
|
createClientSchema,
|
||||||
updateClientSchema,
|
updateClientSchema,
|
||||||
CLIENT_TYPES,
|
CLIENT_TYPES,
|
||||||
type CreateClientFormValues,
|
|
||||||
type UpdateClientFormValues,
|
|
||||||
} from "@/src/validations/client.validator";
|
} from "@/src/validations/client.validator";
|
||||||
import type { Client } from "@/src/types/client";
|
import type { Client, ClientFormData } from "@/src/types/client";
|
||||||
|
|
||||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||||
const S = {
|
const S = {
|
||||||
@@ -52,7 +50,7 @@ interface ClientFormModalProps {
|
|||||||
editClient: Client | null;
|
editClient: Client | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
data: CreateClientFormValues | UpdateClientFormValues,
|
data: ClientFormData,
|
||||||
isNew: boolean
|
isNew: boolean
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
@@ -62,20 +60,19 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
|
|||||||
const isNew = editClient === null;
|
const isNew = editClient === null;
|
||||||
|
|
||||||
// FIX 1: was `Schema` (capital S) — now correctly `schema`
|
// FIX 1: was `Schema` (capital S) — now correctly `schema`
|
||||||
const schema = isNew ? createClientSchema : updateClientSchema;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<CreateClientFormValues | UpdateClientFormValues>({
|
} = useForm<ClientFormData>({
|
||||||
resolver: yupResolver(schema) as never,
|
resolver: yupResolver(isNew ? createClientSchema : updateClientSchema) as never,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: editClient?.name ?? "",
|
name: editClient?.name ?? "",
|
||||||
email: editClient?.email ?? "",
|
email: editClient?.email ?? "",
|
||||||
phone: editClient?.phone ?? "",
|
phone: editClient?.phone ?? "",
|
||||||
clientType: editClient?.clientType ?? undefined,
|
clientType: editClient?.clientType ?? undefined,
|
||||||
|
isActive: editClient?.isActive ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +82,7 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
|
|||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const submitHandler = async (data: CreateClientFormValues | UpdateClientFormValues) => {
|
const submitHandler = async (data: ClientFormData) => {
|
||||||
const ok = await onSubmit(data, isNew);
|
const ok = await onSubmit(data, isNew);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
317
src/Components/Client/archive/ArchivedClientDetailModal.tsx
Normal file
317
src/Components/Client/archive/ArchivedClientDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/Components/Client/archive/ArchivedClientTable.tsx
Normal file
134
src/Components/Client/archive/ArchivedClientTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/Components/Client/archive/ArchivedClientsModal.tsx
Normal file
114
src/Components/Client/archive/ArchivedClientsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -106,10 +106,30 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type AddressFieldError = {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AddressErrors = {
|
type AddressErrors = {
|
||||||
details?: Record<string, unknown>;
|
details?: {
|
||||||
contactPerson?: Record<string, unknown>;
|
street?: AddressFieldError;
|
||||||
location?: Record<string, unknown>;
|
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 {
|
const {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Spinner } from "../UI";
|
import { Spinner } from "../UI";
|
||||||
import { CarImageGallery } from "./CarImageGallery";
|
import { CarImageGallery } from "./CarImageGallery";
|
||||||
import { useCarDetail } from "@/src/hooks/useCars";
|
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) {
|
export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPanelProps) {
|
||||||
const { car, loading, error } = useCarDetail(carId);
|
const { car, loading, error } = useCarDetail(carId);
|
||||||
const [gallery, setGallery] = useState(false);
|
const [gallery, setGallery] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape" && !gallery) onClose(); };
|
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",
|
padding: "1rem 1.5rem",
|
||||||
borderTop: "1px solid var(--color-border)",
|
borderTop: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface-muted)",
|
background: "var(--color-surface-muted)",
|
||||||
display: "flex", gap: "0.75rem",
|
display: "flex", flexDirection: "column", gap: "0.75rem",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
|
{/* Row 1: secondary views */}
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
<button type="button" onClick={() => setGallery(true)}
|
<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)" }}>
|
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">
|
<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>
|
</svg>
|
||||||
الصور
|
الصور
|
||||||
</button>
|
</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)}
|
<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)" }}>
|
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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
const [stage, setStage] = useState<ImageStage>("GENERAL");
|
const [stage, setStage] = useState<ImageStage>("GENERAL");
|
||||||
const [sortBy, setSortBy] = useState<"asc" | "desc">("desc");
|
const [sortBy, setSortBy] = useState<"asc" | "desc">("desc");
|
||||||
const [lightbox, setLightbox] = useState<CarImage | null>(null);
|
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 fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -47,6 +49,18 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
|
|
||||||
const imgSrc = (img: CarImage) => img.url ?? `/api/proxy/car-photos/${img.image}`;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog" aria-modal="true" aria-label="معرض صور المركبة"
|
role="dialog" aria-modal="true" aria-label="معرض صور المركبة"
|
||||||
@@ -141,6 +155,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
{images.map(img => {
|
{images.map(img => {
|
||||||
const stageInfo = STAGE_MAP[img.stage ?? "GENERAL"] ?? STAGE_MAP.GENERAL;
|
const stageInfo = STAGE_MAP[img.stage ?? "GENERAL"] ?? STAGE_MAP.GENERAL;
|
||||||
const isDeleting = deleting === img.id;
|
const isDeleting = deleting === img.id;
|
||||||
|
const isLoaded = loadedIds.has(img.id);
|
||||||
return (
|
return (
|
||||||
<div key={img.id} style={{
|
<div key={img.id} style={{
|
||||||
borderRadius: "var(--radius-xl)",
|
borderRadius: "var(--radius-xl)",
|
||||||
@@ -151,13 +166,27 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
opacity: isDeleting ? 0.5 : 1,
|
opacity: isDeleting ? 0.5 : 1,
|
||||||
transition: "opacity 200ms",
|
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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={imgSrc(img)}
|
src={imgSrc(img)}
|
||||||
alt={`صورة ${stageInfo.label}`}
|
alt={`صورة ${stageInfo.label}`}
|
||||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
|
style={{
|
||||||
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }}
|
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={{
|
<span style={{
|
||||||
position: "absolute", top: 8, right: 8,
|
position: "absolute", top: 8, right: 8,
|
||||||
@@ -196,13 +225,23 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
{/* ── Lightbox ── */}
|
{/* ── Lightbox ── */}
|
||||||
{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" }}>
|
<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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={imgSrc(lightbox)}
|
src={imgSrc(lightbox)}
|
||||||
alt="عرض مكبَّر"
|
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()}
|
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" }}>
|
<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" }}>
|
||||||
×
|
×
|
||||||
|
|||||||
233
src/Components/role/archive/ArchivedRoleDetailModal.tsx
Normal file
233
src/Components/role/archive/ArchivedRoleDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/Components/role/archive/ArchivedRoleTable.tsx
Normal file
131
src/Components/role/archive/ArchivedRoleTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/Components/role/archive/ArchivedRolesModal.tsx
Normal file
115
src/Components/role/archive/ArchivedRolesModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { carMaintenanceService } from "@/src/services/carMaintanance.service";
|
import { carMaintenanceService } from "@/src/services/carMaintanance.service";
|
||||||
import type {
|
import type {
|
||||||
@@ -11,10 +11,24 @@ import type {
|
|||||||
} from "../types/carMaintanance";
|
} from "../types/carMaintanance";
|
||||||
|
|
||||||
// ── useCarMaintenanceList ─────────────────────────────────────────────────────
|
// ── 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) {
|
export function useCarMaintenanceList(
|
||||||
const [records, setRecords] = useState<CarMaintenance[]>([]);
|
carId: string | null,
|
||||||
|
options?: { includeDeleted?: boolean },
|
||||||
|
) {
|
||||||
|
const includeDeleted = options?.includeDeleted ?? false;
|
||||||
|
|
||||||
|
const [allRecords, setAllRecords] = useState<CarMaintenance[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -26,8 +40,9 @@ export function useCarMaintenanceList(carId: string | null) {
|
|||||||
carMaintenanceService
|
carMaintenanceService
|
||||||
.getAll(carId, token)
|
.getAll(carId, token)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const payload = (res as unknown as { data: { data: CarMaintenance[] } }).data ?? res;
|
const list = (res as unknown as { data: CarMaintenance[] }).data ?? [];
|
||||||
setRecords((payload 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))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -35,9 +50,14 @@ export function useCarMaintenanceList(carId: string | null) {
|
|||||||
|
|
||||||
useEffect(() => { queueMicrotask(loadRecords); }, [loadRecords]);
|
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.
|
// Remove a record from the list right away, instead of waiting on a refetch.
|
||||||
const removeRecord = useCallback((id: string) => {
|
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 };
|
return { records, loading, error, loadRecords, removeRecord, setError };
|
||||||
@@ -45,6 +65,10 @@ export function useCarMaintenanceList(carId: string | null) {
|
|||||||
|
|
||||||
// ── useCarMaintenanceDetail ───────────────────────────────────────────────────
|
// ── useCarMaintenanceDetail ───────────────────────────────────────────────────
|
||||||
// Fetches a single maintenance record by id.
|
// 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) {
|
export function useCarMaintenanceDetail(carId: string | null, maintenanceId: string | null) {
|
||||||
const [record, setRecord] = useState<CarMaintenance | null>(null);
|
const [record, setRecord] = useState<CarMaintenance | null>(null);
|
||||||
@@ -68,6 +92,59 @@ export function useCarMaintenanceDetail(carId: string | null, maintenanceId: str
|
|||||||
return { record, loading, error };
|
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 ────────────────────────────────────────────────
|
// ── useCarMaintenanceMutations ────────────────────────────────────────────────
|
||||||
// Create, update, and soft-delete operations for maintenance records.
|
// Create, update, and soft-delete operations for maintenance records.
|
||||||
|
|
||||||
@@ -98,8 +175,10 @@ export function useCarMaintenanceMutations({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
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);
|
await carMaintenanceService.create(carId, payload as CreateMaintenancePayload, token);
|
||||||
onSuccess("تم إضافة سجل الصيانة بنجاح.");
|
onSuccess("تم إضافة سجل الصيانة بنجاح، وتم تحديث حالة المركبة إلى صيانة.");
|
||||||
} else {
|
} else {
|
||||||
const editTarget = getEditTarget();
|
const editTarget = getEditTarget();
|
||||||
if (!editTarget) return false;
|
if (!editTarget) return false;
|
||||||
@@ -113,7 +192,6 @@ export function useCarMaintenanceMutations({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Show the backend's own message when it has one, otherwise a generic fallback.
|
|
||||||
onError(err instanceof Error ? err.message : "فشلت العملية.");
|
onError(err instanceof Error ? err.message : "فشلت العملية.");
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -128,9 +206,12 @@ export function useCarMaintenanceMutations({
|
|||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
try {
|
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);
|
await carMaintenanceService.delete(carId, target.id, token);
|
||||||
onDeleted(target.id);
|
onDeleted(target.id);
|
||||||
onSuccess("تم حذف سجل الصيانة بنجاح.");
|
onSuccess("تم حذف سجل الصيانة، وتم إرجاع حالة المركبة إلى نشط.");
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
onError(err instanceof Error ? err.message : "فشل الحذف.");
|
onError(err instanceof Error ? err.message : "فشل الحذف.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { carService } from "@/src/services/car.service";
|
import { carService } from "@/src/services/car.service";
|
||||||
import type { Car } from "@/src/types/car";
|
import type { Car } from "@/src/types/car";
|
||||||
|
|
||||||
const PAGE_SIZE = 12;
|
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() {
|
export function useArchivedCars() {
|
||||||
const [allCars, setAllCars] = useState<Car[]>([]);
|
const [allCars, setAllCars] = useState<Car[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/hooks/archive/useArchivedClients.ts
Normal file
43
src/hooks/archive/useArchivedClients.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,15 +53,16 @@ export function useCars(page: number, search: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── useCarDetail ──────────────────────────────────────────────────────────────
|
// ── 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) {
|
export function useCarDetail(carId: string) {
|
||||||
const [car, setCar] = useState<Car | null>(null);
|
const [car, setCar] = useState<Car | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadCar = useCallback(() => {
|
||||||
queueMicrotask(() => {
|
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -70,10 +71,11 @@ export function useCarDetail(carId: string) {
|
|||||||
.then(res => setCar((res as unknown as { data: Car }).data))
|
.then(res => setCar((res as unknown as { data: Car }).data))
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
});
|
|
||||||
}, [carId]);
|
}, [carId]);
|
||||||
|
|
||||||
return { car, loading, error };
|
useEffect(() => { queueMicrotask(loadCar); }, [loadCar]);
|
||||||
|
|
||||||
|
return { car, loading, error, refetch: loadCar };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── useCarMutations ───────────────────────────────────────────────────────────
|
// ── useCarMutations ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export function useRoles() {
|
|||||||
async (
|
async (
|
||||||
id: string,
|
id: string,
|
||||||
data: RoleFormData,
|
data: RoleFormData,
|
||||||
|
currentPermIds: string[]
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export async function request<T>(
|
|||||||
|
|
||||||
const url = `${resolveBase()}/${path.replace(/^\/+/, "")}`;
|
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, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
@@ -41,9 +46,17 @@ export async function request<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const json = await res.json().catch(() => null);
|
// Try to parse JSON body for a helpful message. In dev, log the
|
||||||
const message: string =
|
// full response body to make debugging backend validation errors
|
||||||
json?.message ?? json?.error ?? `HTTP ${res.status}: ${res.statusText}`;
|
// 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);
|
throw new ApiError(res.status, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
};
|
||||||
@@ -35,6 +35,8 @@ export const carMaintenanceService = {
|
|||||||
/**
|
/**
|
||||||
* POST /cars/:carId/maintenance
|
* POST /cars/:carId/maintenance
|
||||||
* Create a new maintenance record for a car.
|
* 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:
|
* Example request body:
|
||||||
* { "reason": "تغيير زيت", "cost": 150, "startAt": "2026-01-10T00:00:00.000Z" }
|
* { "reason": "تغيير زيت", "cost": 150, "startAt": "2026-01-10T00:00:00.000Z" }
|
||||||
@@ -62,17 +64,26 @@ export const carMaintenanceService = {
|
|||||||
/**
|
/**
|
||||||
* DELETE /cars/:carId/maintenance/:maintenanceId
|
* DELETE /cars/:carId/maintenance/:maintenanceId
|
||||||
* Soft-delete a maintenance record (returns 204 No Content).
|
* 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) =>
|
delete: (carId: string, maintenanceId: string, token: string | null) =>
|
||||||
del<void>(`cars/${carId}/maintenance/${maintenanceId}`, token),
|
del<void>(`cars/${carId}/maintenance/${maintenanceId}`, token),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cars/:carId/maintenance/archived
|
* GET /cars/:carId/maintenance/archived
|
||||||
* Fetch soft-deleted maintenance records for a car — mirrors the car
|
* Fetch soft-deleted maintenance records for a single car.
|
||||||
* module's archive pattern in case the backend exposes the same route.
|
|
||||||
*/
|
*/
|
||||||
getArchived: (carId: string, token: string | null) =>
|
getArchived: (carId: string, token: string | null) =>
|
||||||
get<MaintenanceListResponse>(`cars/${carId}/maintenance/archived`, token),
|
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…)
|
// Re-exported so callers can build extra filters (day/month/year, search…)
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ export const clientService = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
|
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
|
||||||
function buildPayload(data: ClientFormData): Record<string, string> {
|
function buildPayload(data: ClientFormData): Record<string, string | boolean> {
|
||||||
const payload: Record<string, string> = {
|
const payload: Record<string, string | boolean> = {};
|
||||||
name: data.name.trim(),
|
|
||||||
email: data.email.trim(),
|
if (data.name?.trim()) payload.name = data.name.trim();
|
||||||
phone: data.phone.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.taxId?.trim()) payload.taxId = data.taxId.trim();
|
||||||
if (data.notes?.trim()) payload.notes = data.notes.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;
|
return payload;
|
||||||
}
|
}
|
||||||
@@ -81,3 +81,43 @@ export interface BranchDetail extends Branch {
|
|||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
deletedAt: string | null;
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,22 +3,41 @@
|
|||||||
// modules feel consistent to work with.
|
// modules feel consistent to work with.
|
||||||
|
|
||||||
// ── Status helpers ───────────────────────────────────────────────────────────
|
// ── Status helpers ───────────────────────────────────────────────────────────
|
||||||
// A maintenance record is "ongoing" while it has no end date yet, and
|
// Worked out entirely on the frontend from endAt + isDeleted — the backend
|
||||||
// "completed" once endAt is filled in. This is worked out on the frontend —
|
// itself never sends a status field.
|
||||||
// the backend does not send a status field.
|
export type MaintenanceStatus = "Ongoing" | "EndingToday" | "Completed" | "Archived";
|
||||||
export type MaintenanceStatus = "Ongoing" | "Completed";
|
|
||||||
|
|
||||||
export const MAINTENANCE_STATUS_MAP: Record<
|
export const MAINTENANCE_STATUS_MAP: Record<MaintenanceStatus, { label: string; color: string; bg: string; border: string; dot: string }> = {
|
||||||
MaintenanceStatus,
|
|
||||||
{ label: string; color: string; bg: string; border: string; dot: string }
|
|
||||||
> = {
|
|
||||||
Ongoing: { label: "جارية", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
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" },
|
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 {
|
* Works out a record's status:
|
||||||
return record.endAt ? "Completed" : "Ongoing";
|
* - 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 ──────────────────────────────────────────────
|
// ── Shared date + money helpers ──────────────────────────────────────────────
|
||||||
@@ -61,6 +80,10 @@ export interface CarMaintenance {
|
|||||||
endAt?: string | null;
|
endAt?: string | null;
|
||||||
|
|
||||||
isActive: boolean;
|
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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
||||||
@@ -91,11 +114,7 @@ export type UpdateMaintenancePayload = Partial<CreateMaintenancePayload> & {
|
|||||||
// ── API Responses ─────────────────────────────────────────────────────────────
|
// ── API Responses ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MaintenanceListResponse {
|
export interface MaintenanceListResponse {
|
||||||
data: {
|
|
||||||
data: CarMaintenance[];
|
data: CarMaintenance[];
|
||||||
pagination?: { total: number; page: number; pages: number };
|
|
||||||
meta?: { total: number; pages: number };
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaintenanceDetailResponse {
|
export interface MaintenanceDetailResponse {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface Client {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
clientType?: "Individual" | "Corporate";
|
||||||
|
isActive?: boolean;
|
||||||
taxId?: string;
|
taxId?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -35,11 +37,13 @@ export type UpdateClientPayload = Partial<CreateClientPayload>;
|
|||||||
// ─── Form & Validation ────────────────────────────────────────────────────────
|
// ─── Form & Validation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ClientFormData {
|
export interface ClientFormData {
|
||||||
name: string;
|
name?: string;
|
||||||
email: string;
|
email?: string;
|
||||||
phone: string;
|
phone?: string;
|
||||||
taxId?: string;
|
taxId?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
clientType?: "Individual" | "Corporate";
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientFormErrors {
|
export interface ClientFormErrors {
|
||||||
@@ -88,3 +92,65 @@ export interface ApiError {
|
|||||||
message: string;
|
message: string;
|
||||||
errors?: Record<string, 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -81,3 +81,50 @@ export type AddressTableAction =
|
|||||||
| { type: "UPDATE"; address: ClientAddress }
|
| { type: "UPDATE"; address: ClientAddress }
|
||||||
| { type: "DELETE"; id: string }
|
| { type: "DELETE"; id: string }
|
||||||
| { type: "CLEAR_ERR" };
|
| { 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[];
|
||||||
|
}
|
||||||
@@ -78,3 +78,25 @@ export interface ApiErrorResponse {
|
|||||||
details: ApiErrorDetail[];
|
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[];
|
||||||
|
}
|
||||||
@@ -43,10 +43,26 @@ export const createClientSchema = yup.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((val) => (val === "" ? undefined : val)),
|
.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
|
clientType: yup
|
||||||
.mixed<ClientType>()
|
.mixed<ClientType>()
|
||||||
.oneOf([...CLIENT_TYPES] as ClientType[], "نوع العميل غير صالح")
|
.oneOf([...CLIENT_TYPES] as ClientType[], "نوع العميل غير صالح")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
|
isActive: yup
|
||||||
|
.boolean()
|
||||||
|
.optional(),
|
||||||
}).required();
|
}).required();
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user