fix: resolve permission fallback bug, remove debug code, and cleanup duplication
Logic Errors ------------ - Sidebar: fix permissions fallback so a user with no permissions gets zero access instead of falling back to the full nav list (user?.permissions ?? [] instead of ?? navSections.flatMap(...)) - carMaintanance.service / UseCarsMaintanance: add pagination to the maintenance records fetch instead of loading the entire list at once - api.ts: redact sensitive fields (password, token) before logging the request body in console.debug, to avoid leaking credentials/PII Code Flow Problems ------------------- - OrderFormModal: remove leftover console.log/debug statements and the redundant onClick handler on the submit button - auth: remove the unused src/service/auth.service.ts (axios) and keep only src/services/auth.service.ts (fetch); fix useAuth.ts import path from
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
// app/components/layout/ConditionalNavbar.tsx
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getStoredUser } from "@/src/lib/auth";
|
||||
import { useStoredUser } from "@/src/hooks/useStoredUser";
|
||||
import Navbar from "./Navbar";
|
||||
|
||||
export default function ConditionalNavbar() {
|
||||
const pathname = usePathname();
|
||||
const user = getStoredUser();
|
||||
|
||||
// استبدلنا getStoredUser() المباشر بالـ hook عشان يبقى SSR-safe
|
||||
const { user, loading } = useStoredUser();
|
||||
|
||||
// لحد ما نتأكد من حالة تسجيل الدخول من الـ storage، منوريش/مخفيش
|
||||
// الناف بار عشان نتجنب وميض (flash) قبل الـ hydration يخلص. مفيش
|
||||
// حاجة تتعرض هنا أصلاً (مفيش avatar/اسم في الناف بار العام)، فـ null
|
||||
// كافية ومفيش داعي لـ Spinner/InlineLoader في المكان ده.
|
||||
if (loading) return null;
|
||||
|
||||
// لو المستخدم مسجل دخول، أو الصفحة الحالية جوه الداشبورد، منظهرش الناف بار العام
|
||||
const isDashboardRoute = pathname?.startsWith("/dashboard");
|
||||
|
||||
@@ -363,6 +363,7 @@ function SidebarContent({
|
||||
pathname.startsWith(item.href + "/"));
|
||||
return (
|
||||
<Link
|
||||
suppressHydrationWarning
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
title={compact ? item.label : undefined}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
// app/components/layout/Topbar.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { clearAuth, getStoredUser } from "@/src/lib/auth";
|
||||
import { clearAuth } from "@/src/lib/auth";
|
||||
import { useStoredUser } from "@/src/hooks/useStoredUser";
|
||||
import { Spinner } from "@/src/Components/UI/Spinner";
|
||||
import { useSidebarDrawer, BrandIconButton } from "./Sidebar";
|
||||
|
||||
export function Topbar() {
|
||||
const router = useRouter();
|
||||
const { setOpen } = useSidebarDrawer();
|
||||
|
||||
// كان بيتقرأ بشكل sync عن طريق getStoredUser() جوه الرندر — استبدلناه بالـ hook
|
||||
// عشان يبقى SSR-safe ومايعملش hydration mismatch، وكمان يعمل subscribe
|
||||
// لأي تغيير في الـ storage (تسجيل دخول/خروج من تاب تاني).
|
||||
const { user, loading } = useStoredUser();
|
||||
|
||||
async function handleLogout() {
|
||||
await clearAuth();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
const user = getStoredUser();
|
||||
|
||||
return (
|
||||
<header
|
||||
suppressHydrationWarning
|
||||
style={{
|
||||
height: "var(--topbar-height)",
|
||||
background: "#FFFFFF",
|
||||
@@ -52,18 +57,33 @@ export function Topbar() {
|
||||
حسابي
|
||||
</button>
|
||||
|
||||
{/*
|
||||
Loading guard: InlineLoader is meant for card/section-level loading
|
||||
(py-4 + message text) and doesn't fit a small inline pill here, so
|
||||
we use the same underlying <Spinner size="sm" /> the project already
|
||||
ships, just placed inside the existing badge shell.
|
||||
*/}
|
||||
<div
|
||||
suppressHydrationWarning
|
||||
role="status"
|
||||
aria-label={loading ? "جارِ التحميل" : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--color-text-secondary)",
|
||||
background: "var(--color-surface-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-full)",
|
||||
padding: "4px 12px",
|
||||
padding: loading ? "4px 10px" : "4px 12px",
|
||||
minWidth: 40,
|
||||
}}
|
||||
>
|
||||
{user?.role ?? "—"}
|
||||
{loading ? (
|
||||
<Spinner size="sm" className="text-[var(--color-text-muted)]" />
|
||||
) : (
|
||||
user?.role ?? "—"
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Toast, ArchiveButton } from "@/src/Components/UI";
|
||||
import { BranchTable } from "@/src/Components/Branch/BranchTable";
|
||||
import { BranchFormModal } from "@/src/Components/Branch/BranchFormModal";
|
||||
import { BranchDetailModal } from "@/src/Components/Branch/BranchDetailModal";
|
||||
import { DeleteConfirmModal } from "@/src/Components/Branch/DeleteConfirmModal";
|
||||
import { Alert, Toast, ArchiveButton ,ConfirmDialog } from "@/src/Components/UI";
|
||||
import { BranchDetailModal, BranchTable ,BranchFormModal } from "@/src/Components/Branch";
|
||||
import { useBranches } from "@/src/hooks/useBranch";
|
||||
import type { Branch, BranchFormData } from "@/src/types/branch";
|
||||
import { ArchivedBranchesModal } from "@/src/Components/Branch/archive/ArchivedBranchesModal";
|
||||
@@ -74,16 +71,14 @@ export default function BranchesPage() {
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
branch={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => {
|
||||
if (!deleting) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف الفرع"
|
||||
description={`هل أنت متأكد من حذف الفرع ${deleteTarget?.name ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* Archive browser modal */}
|
||||
{archiveOpen && (
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Alert, ArchiveButton, PageHeader, Spinner, Toast } from "@/src/Components/UI";
|
||||
import { Alert, ArchiveButton, ConfirmDialog, 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,
|
||||
@@ -135,14 +134,14 @@ export default function CarMaintenancePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<CarMaintenanceDeleteModal
|
||||
record={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => handleDeleteConfirm(deleteTarget)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => deleteTarget && handleDeleteConfirm(deleteTarget)}
|
||||
title="حذف سجل الصيانة"
|
||||
description={`هل أنت متأكد من حذف سجل ${deleteTarget?.reason ?? ""} (${deleteTarget ? fmtCost(deleteTarget.cost) : ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{archiveOpen && (
|
||||
<ArchivedCarsMaintenanceModal
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Spinner, Alert, ArchiveButton } from "@/src/Components/UI";
|
||||
import { CarFormModal } from "@/src/Components/car/CarFormModal";
|
||||
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
|
||||
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
|
||||
import { Spinner, Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||
import { CarFormModal,CarDetailPanel } from "@/src/Components/car";
|
||||
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
|
||||
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
|
||||
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
|
||||
@@ -283,14 +281,14 @@ export default function CarsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<CarDeleteModal
|
||||
car={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="حذف المركبة"
|
||||
description={`هل أنت متأكد من حذف ${deleteTarget?.manufacturer ?? ""} ${deleteTarget?.model ?? ""} (${deleteTarget?.plateLetters ?? ""} ${deleteTarget?.plateNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{maintenanceTarget && (
|
||||
<CarMaintenanceFormModal
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { Alert, ConfirmDialog, Toast } from "@/src/Components/UI";
|
||||
import { AddressFormModal } from "@/src/Components/Client";
|
||||
import { AddressDetails } from "@/src/Components/Client_Adress/AddressDetails";
|
||||
import { clientAddressService } from "@/src/services/clientAddress.service";
|
||||
import { clientService } from "@/src/services/client.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
|
||||
import type { Client } from "@/src/types/client";
|
||||
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||
import type { ToastNotification } from "@/src/Components/UI";
|
||||
import type {
|
||||
CreateAddressFormValues,
|
||||
UpdateAddressFormValues,
|
||||
} from "@/src/validations/client_address.validator";
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddressDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
const clientId = (params?.clientId ?? params?.id) as string | undefined;
|
||||
const addressId = params?.addressId as string | undefined;
|
||||
|
||||
// ── Data state ───────────────────────────────────────────────────────────
|
||||
const [address, setAddress] = useState<ClientAddress | null>(null);
|
||||
const [client, setClient] = useState<Client | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Modal state ──────────────────────────────────────────────────────────
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// ── Toast ────────────────────────────────────────────────────────────────
|
||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||
|
||||
const notify = (n: ToastNotification) => {
|
||||
setNotification(n);
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
};
|
||||
|
||||
// ── Fetch ────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!clientId || !addressId) return;
|
||||
setLoading(true);
|
||||
|
||||
Promise.all([
|
||||
clientAddressService.getById(clientId, addressId, getStoredToken()),
|
||||
clientService.getById(clientId, getStoredToken()),
|
||||
])
|
||||
.then(([addrRes, clientRes]) => {
|
||||
const raw = (addrRes as any).data ?? addrRes;
|
||||
setAddress({ ...raw, id: raw.id ?? raw._id });
|
||||
setClient(clientRes?.data);
|
||||
})
|
||||
.catch(() => setError("تعذّر تحميل بيانات العنوان. يرجى المحاولة مجدداً."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [clientId, addressId]);
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────────────────
|
||||
const handleUpdateSubmit = async (
|
||||
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||
): Promise<boolean> => {
|
||||
if (!clientId || !addressId) return false;
|
||||
try {
|
||||
const res = await clientAddressService.update(
|
||||
clientId,
|
||||
addressId,
|
||||
data as UpdateAddressFormValues,
|
||||
getStoredToken()
|
||||
);
|
||||
const raw = (res as any).data ?? res;
|
||||
setAddress({ ...raw, id: raw.id ?? raw._id });
|
||||
notify({ type: "success", message: "تم تحديث العنوان بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "تعذّر تحديث العنوان.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!clientId || !addressId || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await clientAddressService.delete(clientId, addressId, getStoredToken());
|
||||
notify({ type: "success", message: "تم حذف العنوان بنجاح." });
|
||||
// short delay so the toast shows before navigation
|
||||
setTimeout(() => {
|
||||
router.replace(`/dashboard/clients/${clientId}/addresses`);
|
||||
}, 700);
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "تعذّر حذف العنوان.",
|
||||
});
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Guards ───────────────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
border: "3px solid var(--color-border)",
|
||||
borderTopColor: "var(--color-brand-600)",
|
||||
animation: "spin 0.7s linear infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !address) {
|
||||
return (
|
||||
<section style={{ padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<Alert type="error" message={error ?? "لم يتم العثور على العنوان."} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-brand-600)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
← رجوع
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
<Toast notification={notification} />
|
||||
|
||||
{/* Edit modal */}
|
||||
{editOpen && (
|
||||
<AddressFormModal
|
||||
editAddress={address}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSubmit={handleUpdateSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
loading={deleting}
|
||||
onCancel={() => { if (!deleting) setDeleteOpen(false); }}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف العميل"
|
||||
description={`هل أنت متأكد من حذف ${address.label}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* ── Page header ── */}
|
||||
<header
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
padding: "1.5rem 2rem",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}
|
||||
>
|
||||
{/* Back link */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/clients/${clientId}/addresses`)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-muted)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
marginBottom: 12,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
{client ? `${client.name} — العناوين` : "العناوين"}
|
||||
</button>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.3em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
تفاصيل العنوان
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{address.label}
|
||||
</h1>
|
||||
{client && (
|
||||
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{client.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid #BFDBFE",
|
||||
background: "#EFF6FF",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#1D4ED8",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل العنوان
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#DC2626",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4h6v2" />
|
||||
</svg>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Address details component ── */}
|
||||
<AddressDetails address={address} />
|
||||
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { Alert, Toast, ArchiveButton } from "@/src/Components/UI";
|
||||
import { Alert, ConfirmDialog, Toast, ArchiveButton } from "@/src/Components/UI";
|
||||
|
||||
import { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
||||
import { clientService } from "@/src/services/client.service";
|
||||
@@ -12,7 +12,7 @@ import { getStoredToken } from "@/src/lib/auth";
|
||||
import type { Client, ClientFormData } from "@/src/types/client";
|
||||
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||
|
||||
import { AddressFormModal, DeleteConfirmModal, ClientFormModal } from "@/src/Components/Client";
|
||||
import { AddressFormModal, ClientFormModal } from "@/src/Components/Client";
|
||||
import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal";
|
||||
import type {
|
||||
CreateAddressFormValues,
|
||||
@@ -443,23 +443,14 @@ export default function ClientAddressesPage() {
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
client={
|
||||
{
|
||||
...deleteTarget,
|
||||
name: deleteTarget.label,
|
||||
email: "",
|
||||
phone: "",
|
||||
createdAt: deleteTarget.createdAt,
|
||||
updatedAt: deleteTarget.updatedAt,
|
||||
} as Client
|
||||
}
|
||||
deleting={deleting}
|
||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف العميل"
|
||||
description={`هل أنت متأكد من حذف ${deleteTarget?.label ?? ""}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* Client edit modal */}
|
||||
{editingClient && client && (
|
||||
|
||||
@@ -6,14 +6,12 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
// ── UI components ──────────────────────────────────────────────────────────
|
||||
// NOTE: Toast is now imported from the canonical UI barrel, NOT from Client/Toast.
|
||||
import { Alert, Toast, ArchiveButton } from "@/src/Components/UI";
|
||||
import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||
|
||||
// ── Client-specific components ─────────────────────────────────────────────
|
||||
import { useClients } from "@/src/hooks/useClients";
|
||||
import type { Client, ClientFormData } from "@/src/types/client";
|
||||
import { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
|
||||
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
||||
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
||||
import { ClientFormModal , ClientTable } from "@/src/Components/Client";
|
||||
import { ArchivedClientsModal } from "@/src/Components/Client/archive/ArchivedClientsModal";
|
||||
|
||||
|
||||
@@ -82,14 +80,14 @@ export default function ClientsPage() {
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
client={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف العميل"
|
||||
description={`هل أنت متأكد من حذف ${deleteTarget?.name ?? ""}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* Archive browser modal */}
|
||||
{archiveOpen && (
|
||||
@@ -258,4 +256,4 @@ export default function ClientsPage() {
|
||||
<ArchiveButton onClick={() => setArchiveOpen(true)} label="أرشيف العملاء" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Spinner } from "@/src/Components/UI";
|
||||
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
|
||||
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
|
||||
import { ConfirmDialog, Spinner } from "@/src/Components/UI";
|
||||
import { DriverFormModal , PhotoCard } from "@/src/Components/Driver";
|
||||
import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
|
||||
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
|
||||
import { DRIVER_STATUS_MAP, DRIVER_CARD_TYPE_MAP, NATIONAL_ID_TYPE_MAP } from "@/src/types/driver";
|
||||
import { PhotoCard } from "@/src/Components/Driver/DriverPhotos";
|
||||
import { driverService } from "@/src/services";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
@@ -26,8 +22,6 @@ function isExpiringSoon(iso?: string | null): boolean {
|
||||
return (new Date(iso).getTime() - Date.now()) <= 90 * 86_400_000;
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -73,10 +67,6 @@ function DetailRow({ label, value, mono = false, warn = false }: {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DriverDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@@ -86,11 +76,9 @@ export default function DriverDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
// Reset avatar error when photo changes after an update
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setAvatarError(false));
|
||||
}, [driver?.photoUrl]);
|
||||
// Toast shown after edit/delete actions on this page
|
||||
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -102,15 +90,14 @@ export default function DriverDetailPage() {
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
}, []);
|
||||
|
||||
// ── Load driver ───────────────────────────────────────────────────────────
|
||||
const loadDriver = useCallback(async () => {
|
||||
if (!driverId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await driverService.getById(driverId, token);
|
||||
setDriver((res as unknown as { data: Driver }).data);
|
||||
const data = await driverService.getById(driverId, token);
|
||||
setDriver(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.");
|
||||
} finally {
|
||||
@@ -120,7 +107,6 @@ export default function DriverDetailPage() {
|
||||
|
||||
useEffect(() => { queueMicrotask(loadDriver); }, [loadDriver]);
|
||||
|
||||
// ── Edit submit ───────────────────────────────────────────────────────────
|
||||
const handleEditSubmit = useCallback(
|
||||
async (payload: CreateDriverPayload | UpdateDriverPayload): Promise<boolean> => {
|
||||
if (!driver) return false;
|
||||
@@ -132,13 +118,11 @@ export default function DriverDetailPage() {
|
||||
const hasFiles = typedPayload.photo || typedPayload.nationalPhoto || typedPayload.driverCardPhoto;
|
||||
|
||||
if (hasFiles) {
|
||||
// Use multipart — JSON.stringify converts File to {} which fails validation
|
||||
await driverService.updateWithImages(driver.id, typedPayload, token);
|
||||
} else {
|
||||
await driverService.update(driver.id, typedPayload, token);
|
||||
}
|
||||
|
||||
// Re-fetch to reflect updated status, name, photos, etc.
|
||||
await loadDriver();
|
||||
showToast("success", "تم تحديث بيانات السائق بنجاح.");
|
||||
return true;
|
||||
@@ -151,7 +135,6 @@ export default function DriverDetailPage() {
|
||||
[driver, loadDriver, showToast],
|
||||
);
|
||||
|
||||
// ── Delete confirm ────────────────────────────────────────────────────────
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!driver) return;
|
||||
setDeleting(true);
|
||||
@@ -168,7 +151,6 @@ export default function DriverDetailPage() {
|
||||
|
||||
const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null;
|
||||
|
||||
// ── Render: Loading ───────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "6rem 0", color: "var(--color-text-muted)" }}>
|
||||
@@ -178,7 +160,6 @@ export default function DriverDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render: Error ─────────────────────────────────────────────────────────
|
||||
if (error || !driver) {
|
||||
return (
|
||||
<div style={{ maxWidth: 480, margin: "4rem auto", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "1.5rem", textAlign: "center" }}>
|
||||
@@ -195,12 +176,10 @@ export default function DriverDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render: Driver detail ─────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* ── Toast notification ── */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
borderRadius: "var(--radius-lg)",
|
||||
@@ -215,7 +194,6 @@ export default function DriverDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Page header ── */}
|
||||
<header style={{
|
||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
|
||||
@@ -294,7 +272,6 @@ export default function DriverDetailPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Content grid ── */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
|
||||
<SectionCard title="البيانات الشخصية">
|
||||
<DetailRow label="الاسم الكامل" value={driver.name} />
|
||||
@@ -380,14 +357,14 @@ export default function DriverDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteOpen && (
|
||||
<DriverDeleteModal
|
||||
driver={driver}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
loading={deleting}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="حذف السائق"
|
||||
description={`هل أنت متأكد من حذف ${driver.name} (${driver.phone})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Alert, Spinner, ArchiveButton } from "@/src/Components/UI";
|
||||
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
|
||||
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
|
||||
import { Alert, Spinner, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
||||
import { useDrivers } from "@/src/hooks/useDriver";
|
||||
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver";
|
||||
import { DriverDetailPanel } from "@/src/Components/Driver/DriverDetailPanel";
|
||||
import { DriverDetailPanel, DriverFormModal , } from "@/src/Components/Driver";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -413,14 +411,14 @@ export default function DriversPage() {
|
||||
)}
|
||||
|
||||
{/* ── Delete modal ── */}
|
||||
{deleteTarget && (
|
||||
<DriverDeleteModal
|
||||
driver={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="حذف السائق"
|
||||
description={`هل أنت متأكد من حذف ${deleteTarget?.name ?? ""} (${deleteTarget?.phone ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* ── Archive browser modal ── */}
|
||||
{archiveOpen && (
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Alert, Spinner, Toast, ArchiveButton } from "@/src/Components/UI";
|
||||
import { OrderFormModal } from "@/src/Components/Order/OrderFormModal";
|
||||
import { OrderDeleteModal } from "@/src/Components/Order/OrderDeleteModal";
|
||||
import { OrderDetailPanel, ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailBanel";
|
||||
import { Alert, Spinner, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||
import { ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailBanel";
|
||||
import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal";
|
||||
import { useOrders } from "@/src/hooks/useOrder";
|
||||
import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order";
|
||||
import { OrderFormModal , OrderDetailPanel} from "@/src/Components/Order";
|
||||
|
||||
// ── Helpers — copied verbatim from app/dashboard/drivers/page.tsx ───────
|
||||
|
||||
@@ -441,14 +440,14 @@ export default function OrderComponent() {
|
||||
)}
|
||||
|
||||
{/* ── Delete modal ── */}
|
||||
{deleteTarget && (
|
||||
<OrderDeleteModal
|
||||
order={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="حذف الطلب"
|
||||
description={`هل أنت متأكد من حذف الطلب ${deleteTarget?.shipmentNumber ?? ""} الخاص بـ ${deleteTarget?.recipientName ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* ── Archive browser modal ── */}
|
||||
{archiveOpen && (
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, ArchiveButton } from "@/src/Components/UI";
|
||||
import { Alert, ArchiveButton, ConfirmDialog,Toast } from "@/src/Components/UI";
|
||||
import { RoleTable } from "@/src/Components/role/RoleTable";
|
||||
import { RoleFormModal } from "@/src/Components/role/RoleFormModal";
|
||||
import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal";
|
||||
import { DeleteRoleModal } from "@/src/Components/role/DeleteRoleModal";
|
||||
import { Toast } from "@/src/Components/UI";
|
||||
import { RoleFormModal,RoleDetailModal } from "@/src/Components/role";
|
||||
import { useRoles } from "@/src/hooks/useRole";
|
||||
import { Role, RoleFormData } from "@/src/types/role";
|
||||
import { ArchivedRolesModal } from "@/src/Components/role/archive/ArchivedRolesModal";
|
||||
@@ -77,14 +74,14 @@ export default function RolesPage() {
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteTarget && (
|
||||
<DeleteRoleModal
|
||||
role={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف الدور"
|
||||
description={`هل أنت متأكد من حذف دور ${deleteTarget?.name ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* Archive browser modal */}
|
||||
{archiveOpen && (
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Spinner } from "@/src/Components/UI";
|
||||
import { ConfirmDialog, Spinner } from "@/src/Components/UI";
|
||||
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
||||
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
|
||||
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
@@ -206,44 +205,42 @@ export default function TripDetailPage() {
|
||||
|
||||
// ── Load trip ─────────────────────────────────────────────────────────────
|
||||
const loadTrip = useCallback(async () => {
|
||||
if (!tripId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await tripService.getById(tripId, token);
|
||||
setTrip((res as unknown as { data: Trip }).data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tripId]);
|
||||
|
||||
if (!tripId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await tripService.getById(tripId, token);
|
||||
setTrip(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tripId]);
|
||||
useEffect(() => {
|
||||
queueMicrotask(loadTrip);
|
||||
}, [loadTrip]);
|
||||
|
||||
// ── Edit submit ───────────────────────────────────────────────────────────
|
||||
const handleEditSubmit = useCallback(
|
||||
async (
|
||||
payload: CreateTripPayload | UpdateTripPayload,
|
||||
): Promise<boolean> => {
|
||||
if (!trip) return false;
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await tripService.update(trip.id, payload as UpdateTripPayload, token);
|
||||
const updated = (res as unknown as { data: Trip }).data;
|
||||
setTrip(updated);
|
||||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[trip, notify],
|
||||
);
|
||||
async (
|
||||
payload: CreateTripPayload | UpdateTripPayload,
|
||||
): Promise<boolean> => {
|
||||
if (!trip) return false;
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const updated = await tripService.update(trip.id, payload as UpdateTripPayload, token);
|
||||
setTrip(updated);
|
||||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[trip, notify],
|
||||
);
|
||||
|
||||
// ── Delete confirm ────────────────────────────────────────────────────────
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
@@ -558,14 +555,14 @@ export default function TripDetailPage() {
|
||||
)}
|
||||
|
||||
{/* ── Delete modal ── */}
|
||||
{deleteOpen && (
|
||||
<TripDeleteModal
|
||||
trip={trip}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
loading={deleting}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="حذف الرحلة"
|
||||
description={`هل أنت متأكد من حذف رحلة ${trip.title} (${trip.tripNumber})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,10 +31,8 @@ export default function ArchivedTripDetailPage() {
|
||||
setError(null);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
// Archived trips must go through the archived endpoint —
|
||||
// the normal /trip/:id endpoint won't return soft-deleted trips.
|
||||
const res = await tripService.getArchivedById(tripId, token);
|
||||
if (!cancelled) setTrip((res as unknown as { data: Trip }).data);
|
||||
const data = await tripService.getArchivedById(tripId, token);
|
||||
if (!cancelled) setTrip(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("لم يتم العثور على هذه الرحلة في الأرشيف.");
|
||||
} finally {
|
||||
@@ -54,7 +52,6 @@ export default function ArchivedTripDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Edge case: invalid/missing/unreachable trip ID
|
||||
if (error || !trip) {
|
||||
return (
|
||||
<EmptyState
|
||||
|
||||
@@ -4,10 +4,8 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTrips } from "@/src/hooks/useTrip";
|
||||
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
||||
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
|
||||
import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList";
|
||||
import { Alert, Spinner, ArchiveButton } from "@/src/Components/UI";
|
||||
import { Toast } from "@/src/Components/UI/Toast";
|
||||
import { Alert, Spinner, ArchiveButton, ConfirmDialog , Toast } from "@/src/Components/UI";
|
||||
import type {
|
||||
Trip,
|
||||
TripStatus,
|
||||
@@ -748,14 +746,14 @@ export default function TripsPage() {
|
||||
)}
|
||||
|
||||
{/* ── Delete modal ── */}
|
||||
{deleteTarget && (
|
||||
<TripDeleteModal
|
||||
trip={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="حذف الرحلة"
|
||||
description={`هل أنت متأكد من حذف رحلة ${deleteTarget?.title ?? ""} (${deleteTarget?.tripNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* ── Archive browser modal ── */}
|
||||
{archiveOpen && (
|
||||
@@ -768,4 +766,4 @@ export default function TripsPage() {
|
||||
<Toast notification={notification} onDismiss={dismissNotification} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Toast, ArchiveButton } from "@/src/Components/UI";
|
||||
import { UserTable } from "@/src/Components/User/UserTable";
|
||||
import { UserFormModal } from "@/src/Components/User/UserFormModal";
|
||||
import { UserDetailModal } from "@/src/Components/User/UserDetailModal";
|
||||
import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal";
|
||||
import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||
import { UserFormModal,UserDetailModal,UserTable } from "@/src/Components/User";
|
||||
import { useUsers } from "@/src/hooks/useUser";
|
||||
import type { User, UserFormData } from "@/src/types/user";
|
||||
import { ArchivedUsersModal } from "@/src/Components/User/archive/Archivedusersmodal";
|
||||
@@ -76,16 +73,14 @@ export default function UsersPage() {
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
user={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => {
|
||||
if (!deleting) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
loading={deleting}
|
||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف المستخدم"
|
||||
description={`هل أنت متأكد من حذف ${deleteTarget?.name ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* Archive browser modal */}
|
||||
{archiveOpen && (
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function RootLayout({
|
||||
return (
|
||||
// ⚠️ اتصلح: الـ Navbar والـ footer كانوا برة <html>/<body> وده HTML غير صالح
|
||||
<html lang="ar" dir="rtl">
|
||||
<body className="app-shell">
|
||||
<body className="app-shell" suppressHydrationWarning>
|
||||
{/* الناف بار بيظهر بس لو مفيش يوزر مسجل دخول */}
|
||||
<ConditionalNavbar />
|
||||
{children}
|
||||
|
||||
@@ -78,20 +78,20 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
||||
|
||||
// fetch branch details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await branchService.getById(branchId, token);
|
||||
if (!cancelled) setBranch(res.data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات الفرع. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [branchId]);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await branchService.getById(branchId, token);
|
||||
if (!cancelled) setBranch(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات الفرع. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [branchId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
branch: Branch;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({ branch, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog" aria-modal="true" aria-labelledby="branch-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: 400, 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 delete */}
|
||||
<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="branch-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)" }}>{branch.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Client } from "@/src/types/client";
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
client: Client;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({
|
||||
client,
|
||||
deleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteConfirmModalProps) {
|
||||
// Close on Escape — identical to User/DeleteConfirmModal
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="del-client-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: 400,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{/* أيقونة الحذف */}
|
||||
<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="del-client-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)" }}>
|
||||
{client.name}
|
||||
</strong>
|
||||
؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Driver } from "@/src/types/driver";
|
||||
|
||||
interface DriverDeleteModalProps {
|
||||
driver: Driver;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DriverDeleteModal({ driver, deleting, onCancel, onConfirm }: DriverDeleteModalProps) {
|
||||
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="driver-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="driver-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)" }}>{driver.name}</strong>
|
||||
{" "}(
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{driver.phone}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
NATIONAL_ID_TYPE_MAP,
|
||||
} from "@/src/types/driver";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
@@ -28,8 +26,6 @@ function isExpiringSoon(iso?: string | null): boolean {
|
||||
return new Date(iso).getTime() - Date.now() <= 90 * 86_400_000;
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
@@ -89,10 +85,6 @@ function SectionHeading({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── PhotoCard ─────────────────────────────────────────────────────────────────
|
||||
// key={url} on the <img> forces a full remount whenever the URL changes,
|
||||
// which clears any stale onError state from a previously failed load.
|
||||
|
||||
function PhotoCard({ url, label }: { url?: string | null; label: string }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
@@ -161,8 +153,6 @@ function PhotoCard({ url, label }: { url?: string | null; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DriverDetailPanelProps {
|
||||
driverId: string;
|
||||
onClose: () => void;
|
||||
@@ -170,8 +160,6 @@ interface DriverDetailPanelProps {
|
||||
onDelete: (driver: Driver) => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DriverDetailPanel({
|
||||
driverId,
|
||||
onClose,
|
||||
@@ -187,8 +175,8 @@ export function DriverDetailPanel({
|
||||
setError(null);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await driverService.getById(driverId, token);
|
||||
setDriver((res as unknown as { data: Driver }).data);
|
||||
const data = await driverService.getById(driverId, token);
|
||||
setDriver(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.");
|
||||
} finally {
|
||||
@@ -208,7 +196,6 @@ export function DriverDetailPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
@@ -220,7 +207,6 @@ export function DriverDetailPanel({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Slide-in panel */}
|
||||
<aside
|
||||
aria-label="تفاصيل السائق"
|
||||
style={{
|
||||
@@ -238,7 +224,6 @@ export function DriverDetailPanel({
|
||||
overflowY: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
style={{
|
||||
padding: "1.25rem 1.5rem",
|
||||
@@ -249,7 +234,6 @@ export function DriverDetailPanel({
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Avatar — key={photoUrl} forces remount on photo change */}
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
@@ -314,7 +298,6 @@ export function DriverDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable content ── */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
||||
@@ -338,7 +321,6 @@ export function DriverDetailPanel({
|
||||
|
||||
{driver && !loading && (
|
||||
<>
|
||||
{/* Status badges */}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||
{statusConfig && (
|
||||
<span style={{
|
||||
@@ -469,7 +451,6 @@ export function DriverDetailPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer actions ── */}
|
||||
{driver && (
|
||||
<div
|
||||
style={{
|
||||
@@ -537,10 +518,6 @@ export function DriverDetailPanel({
|
||||
);
|
||||
}
|
||||
|
||||
// ── AvatarImg ─────────────────────────────────────────────────────────────────
|
||||
// Separate component so key={src} forces a full remount on photo change,
|
||||
// avoiding stale onError state from a previously failed load.
|
||||
|
||||
function AvatarImg({ src, name }: { src: string; name: string }) {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
|
||||
@@ -72,27 +72,27 @@ export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverD
|
||||
}, [onClose]);
|
||||
|
||||
// fetch archived driver details + status history on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const [driverRes, historyRes] = await Promise.all([
|
||||
archivedDriverService.getById(driverId, token),
|
||||
archivedDriverService.getStatusHistory(driverId, token),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setDriver(driverRes.data);
|
||||
setHistory(historyRes.data ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const [driverData, historyList] = await Promise.all([
|
||||
archivedDriverService.getByIdUnwrapped(driverId, token),
|
||||
archivedDriverService.getStatusHistoryUnwrapped(driverId, token),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setDriver(driverData);
|
||||
setHistory(historyList ?? []);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [driverId]);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [driverId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
|
||||
@@ -5,14 +5,10 @@ import { Spinner } from "../UI";
|
||||
import { driverService } from "@/src/services/driver.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DriverReportPanelProps {
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [date, setDate] = useState(today);
|
||||
@@ -25,15 +21,10 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||
setError(null);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await driverService.getDailyReport(driverId, date, token);
|
||||
// The response shape: { data: { reportUrl, filename } }
|
||||
const reportUrl = (
|
||||
res as unknown as { data: { reportUrl: string; filename: string } }
|
||||
).data?.reportUrl;
|
||||
const { reportUrl } = await driverService.getDailyReport(driverId, date, token);
|
||||
|
||||
if (!reportUrl) throw new Error("لم يتم إرجاع رابط التقرير.");
|
||||
|
||||
// Navigate directly to the report URL
|
||||
window.location.href = reportUrl;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر إنشاء التقرير.");
|
||||
@@ -52,7 +43,6 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
{/* Heading */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
@@ -74,7 +64,6 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{/* Date input */}
|
||||
<div style={{ flex: 1, minWidth: 140 }}>
|
||||
<label
|
||||
htmlFor="report-date"
|
||||
@@ -112,7 +101,6 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
@@ -141,7 +129,6 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Order } from "@/src/types/order";
|
||||
|
||||
interface OrderDeleteModalProps {
|
||||
order: Order;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors DriverDeleteModal.tsx 1:1 — same alertdialog role, same
|
||||
* backdrop/click-outside-to-close behavior, same Escape-to-close effect,
|
||||
* same button layout. Only the copy and the identifying fields differ.
|
||||
*/
|
||||
export function OrderDeleteModal({ order, deleting, onCancel, onConfirm }: OrderDeleteModalProps) {
|
||||
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="order-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="order-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)" }}>{order.shipmentNumber}</strong>
|
||||
{" "}الخاص بـ{" "}
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{order.recipientName}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,7 @@ import { Spinner, Alert } from "../UI";
|
||||
import { orderService } from "@/src/services/order.service";
|
||||
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
||||
|
||||
// ── Status config ────────────────────────────────────────────────────────
|
||||
// Same shape as DRIVER_STATUS_MAP in src/types/driver.ts, kept local here
|
||||
// since order.ts doesn't currently export a colour map of its own.
|
||||
const ORDER_STATUS_MAP: Record<
|
||||
OrderStatus,
|
||||
{ label: string; color: string; bg: string; border: string; dot: string }
|
||||
> = {
|
||||
const ORDER_STATUS_MAP: Record<OrderStatus, { label: string; color: string; bg: string; border: string; dot: string }> = {
|
||||
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
|
||||
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE", dot: "#8B5CF6" },
|
||||
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
||||
@@ -33,7 +27,6 @@ const PAY_METHOD_LABEL: Record<string, string> = {
|
||||
Prepaid: "مدفوع مسبقاً",
|
||||
};
|
||||
|
||||
// ── Helpers — identical to DriverDetailPanel.tsx's fmtDate ──────────────
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
@@ -50,11 +43,6 @@ function fmtAmount(n?: number | string | null): string {
|
||||
return `${num.toFixed(2)} ر.س`;
|
||||
}
|
||||
|
||||
// ── Sub-components — copied verbatim from DriverDetailPanel.tsx ─────────
|
||||
// (DetailRow / SectionHeading carry no Driver-specific logic, so they are
|
||||
// reproduced here rather than imported, matching how the Driver files keep
|
||||
// their detail-panel building blocks local to the panel that uses them.)
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
@@ -114,22 +102,14 @@ function SectionHeading({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface OrderDetailPanelProps {
|
||||
orderId: string;
|
||||
onClose: () => void;
|
||||
onEdit: (order: Order) => void;
|
||||
onDelete: (order: Order) => void;
|
||||
/** Bubble a status change up so the list row updates without a full reload */
|
||||
onStatusChanged?: (order: Order) => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
// Structurally identical to DriverDetailPanel.tsx: fixed backdrop + slide-in
|
||||
// <aside>, header with avatar-equivalent badge, scrollable body of
|
||||
// SectionHeading/DetailRow blocks, sticky footer actions.
|
||||
|
||||
export function OrderDetailPanel({
|
||||
orderId,
|
||||
onClose,
|
||||
@@ -141,7 +121,6 @@ export function OrderDetailPanel({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Inline status-update control state
|
||||
const [statusDraft, setStatusDraft] = useState<OrderStatus | "">("");
|
||||
const [statusReason, setStatusReason] = useState("");
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
@@ -151,8 +130,7 @@ export function OrderDetailPanel({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await orderService.getById(orderId);
|
||||
const data = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
||||
const data = await orderService.getById(orderId);
|
||||
setOrder(data);
|
||||
setStatusDraft(data.currentStatus);
|
||||
} catch (err: unknown) {
|
||||
@@ -170,7 +148,6 @@ export function OrderDetailPanel({
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
// ── Status update — alert shown inline in the panel, success bubbled up ──
|
||||
const handleStatusUpdate = useCallback(async () => {
|
||||
if (!order || !statusDraft || statusDraft === order.currentStatus) return;
|
||||
setUpdatingStatus(true);
|
||||
@@ -178,8 +155,7 @@ export function OrderDetailPanel({
|
||||
try {
|
||||
const payload: UpdateOrderStatusPayload = { status: statusDraft };
|
||||
if (statusReason) payload.reason = statusReason;
|
||||
const res = await orderService.updateStatus(order.id, payload);
|
||||
const updated = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
||||
const updated = await orderService.updateStatus(order.id, payload);
|
||||
setOrder(updated);
|
||||
setStatusReason("");
|
||||
onStatusChanged?.(updated);
|
||||
@@ -195,7 +171,6 @@ export function OrderDetailPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
@@ -207,7 +182,6 @@ export function OrderDetailPanel({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Slide-in panel */}
|
||||
<aside
|
||||
aria-label="تفاصيل الطلب"
|
||||
style={{
|
||||
@@ -225,7 +199,6 @@ export function OrderDetailPanel({
|
||||
overflowY: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
style={{
|
||||
padding: "1.25rem 1.5rem",
|
||||
@@ -236,8 +209,6 @@ export function OrderDetailPanel({
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Shipment "avatar" badge — order has no photo, so a glyph stands in for it,
|
||||
matching the circular-badge slot Driver uses for its avatar image. */}
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
@@ -298,7 +269,6 @@ export function OrderDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable content ── */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
||||
@@ -322,7 +292,6 @@ export function OrderDetailPanel({
|
||||
|
||||
{order && !loading && (
|
||||
<>
|
||||
{/* Status badges */}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||
{statusConfig && (
|
||||
<span style={{
|
||||
@@ -375,7 +344,6 @@ export function OrderDetailPanel({
|
||||
<DetailRow label="ضريبة القيمة المضافة" value={fmtAmount(order.vatAmount)} mono />
|
||||
<DetailRow label="الإجمالي" value={fmtAmount(order.totalPrice)} mono />
|
||||
|
||||
{/* ── Delivery Address ── */}
|
||||
{order.deliveryAddress?.details && Object.values(order.deliveryAddress.details).some(Boolean) && (
|
||||
<>
|
||||
<SectionHeading title="عنوان التسليم" />
|
||||
@@ -394,7 +362,6 @@ export function OrderDetailPanel({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Pickup Address ── */}
|
||||
{order.pickupAddress?.details && Object.values(order.pickupAddress.details).some(Boolean) && (
|
||||
<>
|
||||
<SectionHeading title="عنوان الاستلام" />
|
||||
@@ -416,11 +383,6 @@ export function OrderDetailPanel({
|
||||
<SectionHeading title="معلومات النظام" /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
|
||||
|
||||
{/* ── Inline status update ──
|
||||
Same alert/feedback pattern as the rest of the panel: a
|
||||
local Alert renders any failure, success simply updates
|
||||
the badge above (no extra toast — the parent page's
|
||||
global toast already fires via onStatusChanged). */}
|
||||
<SectionHeading title="تحديث الحالة" />
|
||||
{statusError && (
|
||||
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
||||
@@ -519,7 +481,6 @@ export function OrderDetailPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer actions ── */}
|
||||
{order && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -22,8 +22,6 @@ import type { Client } from "@/src/types/client";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
import type { OrderSchemaErrors } from "@/src/validations/order.validation";
|
||||
|
||||
// ── Shared input / label styles — verbatim from DriverFormModal.tsx ──────────
|
||||
|
||||
const inputBase: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: 40,
|
||||
@@ -68,7 +66,6 @@ const optionalLabelStyle: React.CSSProperties = {
|
||||
marginRight: 4,
|
||||
};
|
||||
|
||||
/** Small "Create new" link rendered below each relation dropdown. */
|
||||
const createLinkStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
color: "var(--color-brand-600)",
|
||||
@@ -80,8 +77,6 @@ const createLinkStyle: React.CSSProperties = {
|
||||
gap: 3,
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface OrderFormModalProps {
|
||||
editOrder: Order | null;
|
||||
onClose: () => void;
|
||||
@@ -91,8 +86,6 @@ interface OrderFormModalProps {
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function OrderFormModal({
|
||||
editOrder,
|
||||
onClose,
|
||||
@@ -100,12 +93,10 @@ export function OrderFormModal({
|
||||
}: OrderFormModalProps) {
|
||||
const isNew = editOrder === null;
|
||||
|
||||
// ── Relation data — clients & trips loaded once on mount ─────────────────
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [relLoading, setRelLoading] = useState(true);
|
||||
|
||||
// ── Form state ────────────────────────────────────────────────────────────
|
||||
const [shipmentNumber, setShipmentNumber] = useState(
|
||||
editOrder?.shipmentNumber ?? "",
|
||||
);
|
||||
@@ -135,8 +126,6 @@ export function OrderFormModal({
|
||||
editOrder?.paymentStatus ?? "",
|
||||
);
|
||||
|
||||
// ── Address state ─────────────────────────────────────────────────────────
|
||||
// Delivery address (عنوان التسليم) — always creates new MongoDB doc
|
||||
const [delCity, setDelCity] = useState(
|
||||
editOrder?.deliveryAddress?.details?.city ?? "",
|
||||
);
|
||||
@@ -155,7 +144,6 @@ export function OrderFormModal({
|
||||
const [delZipCode, setDelZipCode] = useState(
|
||||
editOrder?.deliveryAddress?.details?.zipCode ?? "",
|
||||
);
|
||||
// GeoJSON coordinates [longitude, latitude] — required by backend
|
||||
const [delLng, setDelLng] = useState(
|
||||
editOrder?.deliveryAddress?.location?.coordinates?.[0] != null
|
||||
? String(editOrder.deliveryAddress!.location!.coordinates![0])
|
||||
@@ -167,7 +155,6 @@ export function OrderFormModal({
|
||||
: "",
|
||||
);
|
||||
|
||||
// Pickup address (عنوان الاستلام) — إما ID موجود أو object جديد
|
||||
const [pickupMode, setPickupMode] = useState<"id" | "new">(
|
||||
editOrder?.pickupAddressId ? "id" : "new",
|
||||
);
|
||||
@@ -192,7 +179,6 @@ export function OrderFormModal({
|
||||
const [pkpZipCode, setPkpZipCode] = useState(
|
||||
editOrder?.pickupAddress?.details?.zipCode ?? "",
|
||||
);
|
||||
// GeoJSON coordinates [longitude, latitude] — optional for pickup
|
||||
const [pkpLng, setPkpLng] = useState(
|
||||
editOrder?.pickupAddress?.location?.coordinates?.[0] != null
|
||||
? String(editOrder.pickupAddress!.location!.coordinates![0])
|
||||
@@ -209,36 +195,20 @@ export function OrderFormModal({
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Load clients + trips in parallel on mount ─────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
|
||||
// Fetch first 100 clients (enough for a practical dropdown)
|
||||
const clientRes = await clientService.getAll(1, "", token);
|
||||
const clientPayload = (clientRes as unknown as { data?: { data?: Client[] } }).data ?? clientRes;
|
||||
const clientList: Client[] = (clientPayload as { data?: Client[] }).data ?? [];
|
||||
|
||||
// Fetch first 100 trips (active ones the order can be assigned to)
|
||||
const tripRes = await tripService.getAll(
|
||||
{ page: 1, limit: 100 },
|
||||
token,
|
||||
);
|
||||
const tripPayload = (tripRes as unknown as { data?: { data?: Trip[] } }).data ?? tripRes;
|
||||
const tripList: Trip[] = (tripPayload as { data?: Trip[] }).data ?? [];
|
||||
const { items: clientList } = await clientService.getAll(1, "", token);
|
||||
const { items: tripList } = await tripService.getAll({ page: 1, limit: 100 }, token);
|
||||
|
||||
if (!cancelled) {
|
||||
setClients(clientList);
|
||||
setTrips(tripList);
|
||||
}
|
||||
} catch (err) {
|
||||
// Logged so failures are visible during development instead of
|
||||
// failing completely silently when clientService/tripService error out
|
||||
// (e.g. expired token, CORS, 500). Dropdowns stay empty either way,
|
||||
// and the user can still navigate to the create pages via the helper
|
||||
// links — but now they also see a message explaining why.
|
||||
console.error("OrderFormModal: failed to load clients/trips", err);
|
||||
if (!cancelled) {
|
||||
setApiError(
|
||||
@@ -254,7 +224,6 @@ export function OrderFormModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Keyboard / focus setup ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
firstRef.current?.focus();
|
||||
}, []);
|
||||
@@ -266,7 +235,6 @@ export function OrderFormModal({
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
// ── Dynamic input error style ─────────────────────────────────────────────
|
||||
const inputStyle = (field: keyof OrderSchemaErrors): React.CSSProperties => ({
|
||||
...inputBase,
|
||||
border: errors[field] ? "1px solid var(--color-danger)" : inputBase.border,
|
||||
@@ -279,7 +247,6 @@ export function OrderFormModal({
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Yup validation ────────────────────────────────────────────────────────
|
||||
const runValidation = useCallback(async (): Promise<boolean> => {
|
||||
const schema = isNew ? createOrderSchema : updateOrderSchema;
|
||||
try {
|
||||
@@ -326,7 +293,6 @@ export function OrderFormModal({
|
||||
type,
|
||||
]);
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
||||
e.preventDefault();
|
||||
@@ -334,10 +300,6 @@ export function OrderFormModal({
|
||||
const valid = await runValidation();
|
||||
if (!valid) return;
|
||||
|
||||
// Build clean payload.
|
||||
// NOTE: deliveryAddressId / pickupAddressId are intentionally omitted
|
||||
// from create payloads — the backend creates address records from
|
||||
// deliveryAddressData / pickupAddressData instead.
|
||||
const payload: Record<string, unknown> = {
|
||||
shipmentNumber,
|
||||
recipientName,
|
||||
@@ -346,10 +308,8 @@ export function OrderFormModal({
|
||||
quantity: Number(quantity) || 1,
|
||||
};
|
||||
|
||||
// tripId: only include when a value is selected
|
||||
if (tripId) payload.tripId = tripId;
|
||||
|
||||
// Optional fields — include only when filled
|
||||
if (type) payload.type = type;
|
||||
if (weight) payload.weight = Number(weight);
|
||||
if (subTotal) payload.subTotal = Number(subTotal);
|
||||
@@ -357,7 +317,6 @@ export function OrderFormModal({
|
||||
if (paymentMethod) payload.paymentMethod = paymentMethod;
|
||||
if (paymentStatus) payload.paymentStatus = paymentStatus;
|
||||
|
||||
// ── Build delivery address object (only if at least one field filled) ──
|
||||
const delDetails = {
|
||||
...(delCity && { city: delCity }),
|
||||
...(delDistrict && { district: delDistrict }),
|
||||
@@ -366,7 +325,6 @@ export function OrderFormModal({
|
||||
...(delUnitNo && { unitNo: delUnitNo }),
|
||||
...(delZipCode && { zipCode: delZipCode }),
|
||||
};
|
||||
// coordinates are always included when provided (required by backend GeoJSON schema)
|
||||
const delCoordinates =
|
||||
delLng.trim() && delLat.trim()
|
||||
? [Number(delLng), Number(delLat)]
|
||||
@@ -379,7 +337,6 @@ export function OrderFormModal({
|
||||
};
|
||||
}
|
||||
|
||||
// ── Build pickup address: ID or new object ──
|
||||
if (pickupMode === "id" && pickupAddressId.trim()) {
|
||||
payload.pickupAddressId = pickupAddressId.trim();
|
||||
} else {
|
||||
@@ -428,7 +385,6 @@ export function OrderFormModal({
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
@@ -463,7 +419,6 @@ export function OrderFormModal({
|
||||
margin: "auto",
|
||||
}}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -522,7 +477,6 @@ export function OrderFormModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Form ── */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
@@ -544,7 +498,6 @@ export function OrderFormModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Section: Shipment & Recipient ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
||||
|
||||
<div
|
||||
@@ -554,7 +507,6 @@ export function OrderFormModal({
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Shipment number — required */}
|
||||
<label style={labelStyle}>
|
||||
رقم الشحنة *
|
||||
<input
|
||||
@@ -574,14 +526,12 @@ export function OrderFormModal({
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* ── Client dropdown — required ── */}
|
||||
<label style={labelStyle}>
|
||||
العميل *
|
||||
<select
|
||||
style={{
|
||||
...inputStyle("clientId"),
|
||||
cursor: relLoading ? "wait" : "pointer",
|
||||
// Shift placeholder text right for RTL
|
||||
paddingRight: "0.75rem",
|
||||
}}
|
||||
value={clientId}
|
||||
@@ -605,7 +555,6 @@ export function OrderFormModal({
|
||||
{errors.clientId && (
|
||||
<span style={errorTextStyle}>{errors.clientId}</span>
|
||||
)}
|
||||
{/* Helper link */}
|
||||
<Link
|
||||
href="/dashboard/clients"
|
||||
style={createLinkStyle}
|
||||
@@ -626,7 +575,6 @@ export function OrderFormModal({
|
||||
</Link>
|
||||
</label>
|
||||
|
||||
{/* Recipient name — required */}
|
||||
<label style={labelStyle}>
|
||||
اسم المستلم *
|
||||
<input
|
||||
@@ -644,7 +592,6 @@ export function OrderFormModal({
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Recipient phone — required */}
|
||||
<label style={labelStyle}>
|
||||
رقم جوال المستلم *
|
||||
<input
|
||||
@@ -662,7 +609,6 @@ export function OrderFormModal({
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* ── Trip dropdown — required on create, optional on edit ── */}
|
||||
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
|
||||
{isNew ? "الرحلة *" : "الرحلة"}
|
||||
{!isNew && <span style={optionalLabelStyle}>(اختياري)</span>}
|
||||
@@ -693,7 +639,6 @@ export function OrderFormModal({
|
||||
{errors.tripId && (
|
||||
<span style={errorTextStyle}>{errors.tripId}</span>
|
||||
)}
|
||||
{/* Helper link */}
|
||||
<Link
|
||||
href="/dashboard/trips"
|
||||
style={createLinkStyle}
|
||||
@@ -715,7 +660,6 @@ export function OrderFormModal({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ── Section: Shipment details ── */}
|
||||
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
|
||||
|
||||
<div
|
||||
@@ -725,7 +669,6 @@ export function OrderFormModal({
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Type — optional */}
|
||||
<label style={labelStyle}>
|
||||
نوع الشحنة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
@@ -738,7 +681,6 @@ export function OrderFormModal({
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Quantity — required, defaults to 1 */}
|
||||
<label style={labelStyle}>
|
||||
الكمية *
|
||||
<input
|
||||
@@ -757,7 +699,6 @@ export function OrderFormModal({
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Weight — optional */}
|
||||
<label style={labelStyle}>
|
||||
الوزن (كجم)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
@@ -773,7 +714,6 @@ export function OrderFormModal({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ── Section: Payment ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات الدفع</p>
|
||||
|
||||
<div
|
||||
@@ -783,7 +723,6 @@ export function OrderFormModal({
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Subtotal — optional */}
|
||||
<label style={labelStyle}>
|
||||
الإجمالي الفرعي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
@@ -804,7 +743,6 @@ export function OrderFormModal({
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* VAT rate — optional */}
|
||||
<label style={labelStyle}>
|
||||
نسبة الضريبة (%)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
@@ -825,7 +763,6 @@ export function OrderFormModal({
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Payment method — optional */}
|
||||
<label style={labelStyle}>
|
||||
طريقة الدفع
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
@@ -844,7 +781,6 @@ export function OrderFormModal({
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Payment status — edit-only, mirrors Driver's status-on-edit pattern */}
|
||||
{!isNew && (
|
||||
<label style={labelStyle}>
|
||||
حالة الدفع
|
||||
@@ -866,7 +802,6 @@ export function OrderFormModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section: Delivery Address ── */}
|
||||
<p style={sectionHeadingStyle}>عنوان التسليم</p>
|
||||
<div
|
||||
style={{
|
||||
@@ -941,7 +876,6 @@ export function OrderFormModal({
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
{/* GeoJSON coordinates — required by backend */}
|
||||
<label style={labelStyle}>
|
||||
خط الطول (Longitude) *
|
||||
<input
|
||||
@@ -968,10 +902,8 @@ export function OrderFormModal({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ── Section: Pickup Address ── */}
|
||||
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
|
||||
|
||||
{/* Toggle: existing ID or new address */}
|
||||
<div
|
||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}
|
||||
>
|
||||
@@ -1090,7 +1022,6 @@ export function OrderFormModal({
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
{/* GeoJSON coordinates — optional for pickup */}
|
||||
<label style={labelStyle}>
|
||||
خط الطول (Longitude)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
@@ -1120,7 +1051,6 @@ export function OrderFormModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -1178,4 +1108,4 @@ export function OrderFormModal({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
2
src/Components/Order/index.ts
Normal file
2
src/Components/Order/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export {OrderFormModal} from './OrderFormModal';
|
||||
export {OrderDetailPanel} from './OrderDetailBanel';
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
|
||||
interface TripDeleteModalProps {
|
||||
trip: Trip;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function TripDeleteModal({ trip, deleting, onCancel, onConfirm }: TripDeleteModalProps) {
|
||||
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="trip-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="trip-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)" }}>{trip.title}</strong>
|
||||
{" "}(
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{trip.tripNumber}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { Spinner } from "../UI";
|
||||
import { get } from "@/src/services/api";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import {
|
||||
createTripSchema,
|
||||
@@ -16,6 +15,12 @@ import type {
|
||||
CreateTripPayload,
|
||||
UpdateTripPayload,
|
||||
} from "@/src/types/trip";
|
||||
import { carService, driverService } from "@/src/services";
|
||||
import { branchService } from "@/src/services/branch.service";
|
||||
import type { DriverOption } from "@/src/types/driver";
|
||||
import type { CarOption } from "@/src/types/car";
|
||||
import type { BranchOption } from "@/src/types/branch";
|
||||
|
||||
|
||||
// ── Shared styles ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,25 +68,9 @@ const optionalLabelStyle: React.CSSProperties = {
|
||||
marginRight: 4,
|
||||
};
|
||||
|
||||
// ── Driver / Car / Branch minimal types ─────────────────────────────────────
|
||||
|
||||
interface DriverOption {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
}
|
||||
interface CarOption {
|
||||
id: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
plateNumber: string;
|
||||
status?: string;
|
||||
}
|
||||
interface BranchOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -108,35 +97,12 @@ export function TripFormModal({
|
||||
const [cars, setCars] = useState<CarOption[]>([]);
|
||||
const [branches, setBranches] = useState<BranchOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
get<{ data: { data: DriverOption[] } }>(
|
||||
"driver?limit=100&status=Active",
|
||||
token,
|
||||
)
|
||||
.then((res) =>
|
||||
setDrivers(
|
||||
(res as unknown as { data: { data: DriverOption[] } }).data?.data ??
|
||||
[],
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
get<{ data: { data: CarOption[] } }>("cars?limit=100¤tStatus=Active", token)
|
||||
.then((res) =>
|
||||
setCars(
|
||||
(res as unknown as { data: { data: CarOption[] } }).data?.data ?? [],
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
get<{ data: { data: BranchOption[] } }>("branches?limit=100", token)
|
||||
.then((res) =>
|
||||
setBranches(
|
||||
(res as unknown as { data: { data: BranchOption[] } }).data?.data ??
|
||||
[],
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
driverService.getActiveOptions(token).then(setDrivers).catch(() => {});
|
||||
carService.getActiveOptions(token).then(setCars).catch(() => {});
|
||||
branchService.getOptions(token).then(setBranches).catch(() => {});
|
||||
}, []);
|
||||
//console.log("cars", cars);
|
||||
// ── Form state ────────────────────────────────────────────────────────────
|
||||
const [title, setTitle] = useState(editTrip?.title ?? "");
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Toast } from "../UI/Toast";
|
||||
import { Spinner ,Toast} from "../UI";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import type { TripNotification } from "@/src/hooks/useTrip";
|
||||
@@ -11,7 +10,6 @@ interface TripReportPanelProps {
|
||||
tripId: string;
|
||||
}
|
||||
|
||||
// Extracts a readable message from any error shape the API might return.
|
||||
function extractApiMessage(err: unknown, fallback: string): string {
|
||||
if (err && typeof err === "object") {
|
||||
const e = err as Record<string, unknown>;
|
||||
@@ -40,15 +38,14 @@ export function TripReportPanel({ tripId }: TripReportPanelProps) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await tripService.getReport(tripId, token);
|
||||
const url = (res as unknown as { data: { reportUrl: string } }).data?.reportUrl;
|
||||
const { reportUrl } = await tripService.getReport(tripId, token);
|
||||
|
||||
if (!url) {
|
||||
if (!reportUrl) {
|
||||
notify({ type: "error", message: "لم يتم إرجاع رابط التقرير من الخادم." });
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.open(reportUrl, "_blank", "noopener,noreferrer");
|
||||
notify({ type: "success", message: "تم إنشاء التقرير بنجاح." });
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر إنشاء التقرير.") });
|
||||
@@ -107,7 +104,6 @@ export function TripReportPanel({ tripId }: TripReportPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toast scoped to this panel — sits at the bottom of the screen */}
|
||||
<Toast
|
||||
notification={notification}
|
||||
onDismiss={() => {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { User } from "@/src/types/user";
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
user: User;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({ user, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog" aria-modal="true" aria-labelledby="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: 400, 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 delete */}
|
||||
<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="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)" }}>{user.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -77,20 +77,20 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
||||
|
||||
// fetch user details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await userService.getById(userId, token);
|
||||
if (!cancelled) setUser(res.data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [userId]);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await userService.getById(userId, token);
|
||||
if (!cancelled) setUser(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [userId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
|
||||
@@ -76,21 +76,21 @@ export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailM
|
||||
}, [onClose]);
|
||||
|
||||
// fetch archived user details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await archivedUserService.getById(userId, token);
|
||||
if (!cancelled) setUser(res.data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم المؤرشف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [userId]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await archivedUserService.getById(userId, token);
|
||||
if (!cancelled) setUser(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم المؤرشف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [userId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Car } from "@/src/types/car";
|
||||
|
||||
interface CarDeleteModalProps {
|
||||
car: Car;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function CarDeleteModal({ car, deleting, onCancel, onConfirm }: CarDeleteModalProps) {
|
||||
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="car-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="car-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)" }}>
|
||||
{car.manufacturer} {car.model}
|
||||
</strong>
|
||||
{" "}(
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{car.plateLetters} {car.plateNumber}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { get } from "@/src/services/api";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { createCarSchema, updateCarSchema } from "@/src/validations/car.validator";
|
||||
import {
|
||||
createCarSchema,
|
||||
updateCarSchema,
|
||||
} from "@/src/validations/car.validator";
|
||||
import type {
|
||||
Car,
|
||||
CarFormErrors,
|
||||
@@ -14,6 +16,8 @@ import type {
|
||||
UpdateCarPayload,
|
||||
} from "@/src/types/car";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
import { branchService } from "@/src/services/branch.service";
|
||||
|
||||
|
||||
// ── Shared input style ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,23 +104,22 @@ export function CarFormModal({
|
||||
const isNew = editCar === null;
|
||||
|
||||
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||
const loadBranches = useCallback(() => {
|
||||
if (branchesProp.length > 0) {
|
||||
queueMicrotask(() => setBranches(branchesProp));
|
||||
return;
|
||||
}
|
||||
const token = getStoredToken();
|
||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
|
||||
.then((res) => {
|
||||
const list =
|
||||
(res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
|
||||
queueMicrotask(() => setBranches(list));
|
||||
})
|
||||
.catch(() => {
|
||||
/* silently ignore */
|
||||
});
|
||||
}, [branchesProp]);
|
||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||
const loadBranches = useCallback(() => {
|
||||
if (branchesProp.length > 0) {
|
||||
queueMicrotask(() => setBranches(branchesProp));
|
||||
return;
|
||||
}
|
||||
const token = getStoredToken();
|
||||
branchService
|
||||
.getOptions(token)
|
||||
.then((list) => {
|
||||
queueMicrotask(() => setBranches(list as unknown as Branch[]));
|
||||
})
|
||||
.catch(() => {
|
||||
/* silently ignore */
|
||||
});
|
||||
}, [branchesProp]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBranches();
|
||||
@@ -787,4 +790,4 @@ export function CarFormModal({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Role } from "@/src/types/role";
|
||||
|
||||
interface DeleteRoleModalProps {
|
||||
role: Role;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog" aria-modal="true" aria-labelledby="del-role-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: 400,
|
||||
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="del-role-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)" }}>{role.name}</strong>؟
|
||||
لا يمكن التراجع عن هذا الإجراء.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Permission, Role } from "@/src/types/role";
|
||||
|
||||
interface RoleDetailModalProps {
|
||||
roleId: string;
|
||||
permissions: Permission[]; // full catalog, for the "add permission" select
|
||||
permissions: Permission[];
|
||||
onClose: () => void;
|
||||
onAssign: (roleId: string, permissionId: string) => Promise<boolean>;
|
||||
onRemove: (roleId: string, permissionId: string) => Promise<boolean>;
|
||||
@@ -51,7 +51,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Inline permission mutation state ─────────────────────────────────────────
|
||||
const [pendingPermId, setPendingPermId] = useState("");
|
||||
const [mutating, setMutating] = useState(false);
|
||||
|
||||
@@ -64,8 +63,8 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
const loadRole = async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await roleService.getById(roleId, token);
|
||||
setRole((res as unknown as { data: Role }).data);
|
||||
const data = await roleService.getById(roleId, token);
|
||||
setRole(data);
|
||||
} catch {
|
||||
setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
@@ -78,8 +77,8 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await roleService.getById(roleId, token);
|
||||
if (!cancelled) setRole((res as unknown as { data: Role }).data);
|
||||
const data = await roleService.getById(roleId, token);
|
||||
if (!cancelled) setRole(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
@@ -89,7 +88,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
return () => { cancelled = true; };
|
||||
}, [roleId]);
|
||||
|
||||
// Endpoint 1: POST /role/{id}/permissions
|
||||
const handleAssign = async () => {
|
||||
if (!pendingPermId || mutating) return;
|
||||
setMutating(true);
|
||||
@@ -101,7 +99,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
setMutating(false);
|
||||
};
|
||||
|
||||
// Endpoint 3: DELETE /role/{id}/permissions/{permissionId}
|
||||
const handleRemove = async (permissionId: string) => {
|
||||
if (mutating) return;
|
||||
setMutating(true);
|
||||
@@ -135,7 +132,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
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",
|
||||
@@ -156,7 +152,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
|
||||
{loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||
@@ -173,7 +168,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
|
||||
{!loading && role && (
|
||||
<div dir="rtl">
|
||||
{/* Role icon + name + status */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--color-border)", marginBottom: "0.25rem" }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: "var(--radius-xl)",
|
||||
@@ -197,13 +191,11 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
<DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
|
||||
{role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
|
||||
|
||||
{/* Permissions list */}
|
||||
<div style={{ paddingTop: "0.75rem" }}>
|
||||
<p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 10px" }}>
|
||||
الصلاحيات ({role.permissions?.length ?? 0})
|
||||
</p>
|
||||
|
||||
{/* Assign new permission — Endpoint 1 */}
|
||||
{assignablePermissions.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<select
|
||||
@@ -250,7 +242,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
fontSize: 11, fontWeight: 600, color: "#1D4ED8",
|
||||
}}>
|
||||
{permission.name}
|
||||
{/* Endpoint 3: remove this permission */}
|
||||
<button
|
||||
type="button" onClick={() => handleRemove(permission.id)} disabled={mutating}
|
||||
aria-label={`إزالة ${permission.name}`}
|
||||
@@ -273,7 +264,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end",
|
||||
|
||||
@@ -78,20 +78,20 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
||||
|
||||
// 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]);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await archivedRoleService.getByIdUnwrapped(roleId, token);
|
||||
if (!cancelled) setRole(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات الدور المؤرشف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [roleId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
|
||||
@@ -33,20 +33,19 @@ export function useCarMaintenanceList(
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadRecords = useCallback(() => {
|
||||
if (!carId) return;
|
||||
const token = getStoredToken();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
carMaintenanceService
|
||||
.getAll(carId, token)
|
||||
.then((res) => {
|
||||
const list = (res as unknown as { data: CarMaintenance[] }).data ?? [];
|
||||
list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
setAllRecords(list);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [carId]);
|
||||
if (!carId) return;
|
||||
const token = getStoredToken();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
carMaintenanceService
|
||||
.getAllUnwrapped(carId, token)
|
||||
.then((list) => {
|
||||
list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
setAllRecords(list);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [carId]);
|
||||
|
||||
useEffect(() => { queueMicrotask(loadRecords); }, [loadRecords]);
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ export function useArchivedCars() {
|
||||
carService
|
||||
.getArchived(token)
|
||||
.then((res) => {
|
||||
const payload = (res as unknown as { data: { data: Car[] } }).data ?? res;
|
||||
setAllCars((payload as { data: Car[] }).data ?? []);
|
||||
setAllCars(res ?? []);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
@@ -18,19 +18,18 @@ export function useArchivedClientAddresses(clientId?: string) {
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
setAddresses(await archivedClientAddressService.getAllUnwrapped(token));
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("تعذّر تحميل أرشيف العناوين. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { queueMicrotask(load); }, [load]);
|
||||
|
||||
|
||||
@@ -5,24 +5,7 @@ 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
|
||||
@@ -33,19 +16,18 @@ export function useArchivedRoles() {
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
setAllRoles(await archivedRoleService.getAllUnwrapped(token));
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("تعذّر تحميل قائمة الأدوار المؤرشفة. يرجى المحاولة لاحقاً.");
|
||||
setAllRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { queueMicrotask(load); }, [load]);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ function reducer(s: TableState, a: TableAction): TableState {
|
||||
case "LOAD_START":
|
||||
return { ...s, loading: true, error: null };
|
||||
case "LOAD_OK":
|
||||
return { ...s, loading: false, orders: a.orders };
|
||||
return { ...s, loading: false, orders: Array.isArray(a.orders) ? a.orders : [] };
|
||||
case "LOAD_ERR":
|
||||
return { ...s, loading: false, error: a.error };
|
||||
case "CLEAR_ERR":
|
||||
@@ -50,27 +50,15 @@ export function useArchivedOrders() {
|
||||
|
||||
// ── Fetch archived orders ─────────────────────────────────────────────
|
||||
const load = useCallback(async () => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await archivedOrderService.getAll(token);
|
||||
|
||||
// نفس الـ pattern بتاع useOrder.ts:
|
||||
// get() بترجع axios response كامل، فـ res.data هو الـ JSON body
|
||||
// {success, message, responseAt, data}. الـ "?? res" احتياطي لو
|
||||
// الـ interceptor بتاع axios بيرجّع الـ body مباشرة من غير .data
|
||||
const payload =
|
||||
(res as unknown as { data: { data: ArchivedOrder[] } }).data ?? res;
|
||||
|
||||
dispatch({ type: "LOAD_OK", orders: payload.data ?? [] });
|
||||
} catch {
|
||||
dispatch({
|
||||
type: "LOAD_ERR",
|
||||
error: "تعذّر تحميل الطلبات المؤرشفة. يرجى المحاولة لاحقاً.",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const orders = await archivedOrderService.getAllUnwrapped(token);
|
||||
dispatch({ type: "LOAD_OK", orders });
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل الطلبات المؤرشفة. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
@@ -12,21 +12,19 @@ export function useArchivedUsers() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await archivedUserService.getAll(page, search, token);
|
||||
setUsers(res.data.data);
|
||||
setTotal(res.data.meta.total);
|
||||
setPages(res.data.meta.totalPages);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("تعذّر تحميل قائمة الأرشيف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search]);
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const { items, total, pages } = await archivedUserService.getAllUnwrapped(page, search, token);
|
||||
setUsers(items); setTotal(total); setPages(pages);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("تعذّر تحميل قائمة الأرشيف. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => { queueMicrotask(load); }, [load]);
|
||||
|
||||
|
||||
@@ -46,21 +46,15 @@ export function useBranches() {
|
||||
|
||||
// fetch branches with pagination and search
|
||||
const loadBranches = useCallback(async (p: number, q: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await branchService.getAll(p, q, token);
|
||||
const payload = res.data ?? res;
|
||||
dispatch({
|
||||
type: "LOAD_OK",
|
||||
branches: payload.data ?? [],
|
||||
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
|
||||
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
|
||||
});
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "عذراً، حدث خطأ أثناء تحميل بيانات الفروع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
}, []);
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const { items, total, pages } = await branchService.getAll(p, q, token);
|
||||
dispatch({ type: "LOAD_OK", branches: items, total, pages });
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "عذراً، حدث خطأ أثناء تحميل بيانات الفروع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// reload branches when page or search changes
|
||||
useEffect(() => {
|
||||
@@ -69,27 +63,24 @@ export function useBranches() {
|
||||
|
||||
// create new branch
|
||||
const createBranch = useCallback(async (data: BranchFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await branchService.create(data, token);
|
||||
dispatch({ type: "ADD", branch: res.data });
|
||||
notify({ type: "success", message: "تم إضافة الفرع بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error && err.message
|
||||
? err.message
|
||||
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
|
||||
notify({ type: "error", message: msg });
|
||||
return false;
|
||||
}
|
||||
}, [notify]);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const branch = await branchService.create(data, token);
|
||||
dispatch({ type: "ADD", branch });
|
||||
notify({ type: "success", message: "تم إضافة الفرع بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: err instanceof Error ? err.message : "تعذر الاتصال بالخادم." });
|
||||
return false;
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
// update existing branch
|
||||
const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await branchService.update(id, data, token);
|
||||
dispatch({ type: "UPDATE", branch: res.data });
|
||||
dispatch({ type: "UPDATE", branch: res?.data });
|
||||
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -19,23 +19,22 @@ import type {
|
||||
// Manages the paginated car list for the main page.
|
||||
|
||||
export function useCars(page: number, search: string) {
|
||||
const [cars, setCars] = useState<Car[]>([]);
|
||||
const [cars, setCars] = useState<Car[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pages, setPages] = useState(1);
|
||||
|
||||
const loadCars = useCallback(() => {
|
||||
const token = getStoredToken();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = `?page=${page}&limit=12${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
get<CarListResponse>(`cars${query}`, token)
|
||||
.then((res) => {
|
||||
const payload = (res as unknown as { data: CarListResponse["data"] }).data ?? res;
|
||||
setCars((payload as CarListResponse["data"]).data ?? []);
|
||||
setTotal((payload as CarListResponse["data"]).meta?.total ?? 0);
|
||||
setPages((payload as CarListResponse["data"]).meta?.pages ?? 1);
|
||||
carService
|
||||
.getAll(page, search, token)
|
||||
.then(({ items, total, pages }) => {
|
||||
setCars(items);
|
||||
setTotal(total);
|
||||
setPages(pages);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -43,10 +42,9 @@ export function useCars(page: number, search: string) {
|
||||
|
||||
useEffect(() => { queueMicrotask(loadCars); }, [loadCars]);
|
||||
|
||||
// Optimistic removal after delete
|
||||
const removeCar = useCallback((id: string) => {
|
||||
setCars(prev => prev.filter(c => c.id !== id));
|
||||
setTotal(prev => Math.max(0, prev - 1));
|
||||
setCars((prev) => prev.filter((c) => c.id !== id));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
return { cars, loading, error, total, pages, loadCars, removeCar, setError };
|
||||
@@ -58,9 +56,9 @@ export function useCars(page: number, search: string) {
|
||||
// record is created/updated/deleted elsewhere in the tree.
|
||||
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadCar = useCallback(() => {
|
||||
const token = getStoredToken();
|
||||
@@ -68,16 +66,16 @@ export function useCarDetail(carId: string) {
|
||||
setError(null);
|
||||
carService
|
||||
.getById(carId, token)
|
||||
.then(res => setCar((res as unknown as { data: Car }).data))
|
||||
.then(setCar)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [carId]);
|
||||
|
||||
useEffect(() => { queueMicrotask(loadCar); }, [loadCar]);
|
||||
|
||||
return { car, loading, error, refetch: loadCar };
|
||||
}
|
||||
|
||||
|
||||
// ── useCarMutations ───────────────────────────────────────────────────────────
|
||||
// Create, update, and delete operations — used in the main page.
|
||||
|
||||
@@ -165,8 +163,8 @@ export function useCarImages(carId: string, sortBy: "asc" | "desc") {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await carService.getImages(carId, token, { sortBy });
|
||||
const raw = (res as unknown as { data: CarImage[] }).data ?? [];
|
||||
setImages(raw);
|
||||
// The response shape: { data: CarImage[] }
|
||||
setImages(res);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل الصور");
|
||||
} finally {
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
} from "../types/client_adresses";
|
||||
import { Notification } from "../types/notif";
|
||||
|
||||
|
||||
|
||||
function normalizeAddress(raw: unknown): ClientAddress {
|
||||
const data = raw as { id?: string; _id?: string; [key: string]: unknown };
|
||||
|
||||
@@ -34,7 +32,10 @@ function normalizeAddresses(rawList: unknown[]): ClientAddress[] {
|
||||
}
|
||||
|
||||
// ── Reducer ────────────────────────────────────────────────────────────────
|
||||
function reducer(s: AddressTableState, a: AddressTableAction): AddressTableState {
|
||||
function reducer(
|
||||
s: AddressTableState,
|
||||
a: AddressTableAction,
|
||||
): AddressTableState {
|
||||
switch (a.type) {
|
||||
case "LOAD_START":
|
||||
return { ...s, loading: true, error: null };
|
||||
@@ -48,7 +49,7 @@ function reducer(s: AddressTableState, a: AddressTableAction): AddressTableState
|
||||
return {
|
||||
...s,
|
||||
addresses: s.addresses.map((a2) =>
|
||||
a2.id === a.address.id ? a.address : a2
|
||||
a2.id === a.address.id ? a.address : a2,
|
||||
),
|
||||
};
|
||||
case "DELETE":
|
||||
@@ -91,33 +92,23 @@ export function useClientAddresses(clientId: string) {
|
||||
}, []);
|
||||
|
||||
// ── Fetch ────────────────────────────────────────────────────────────────
|
||||
const loadAddresses = useCallback(async () => {
|
||||
if (!clientId) return;
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientAddressService.getAll(clientId, token);
|
||||
const payload = (res as unknown as { data?: unknown }).data ?? res;
|
||||
|
||||
// FIXED: explicit Array.isArray checks instead of relying on
|
||||
// TS to narrow the ternary's return type on its own.
|
||||
const rawList: unknown[] = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray((payload as { data?: unknown })?.data)
|
||||
? ((payload as { data?: unknown }).data as unknown[])
|
||||
: [];
|
||||
|
||||
dispatch({
|
||||
type: "LOAD_OK",
|
||||
addresses: normalizeAddresses(rawList),
|
||||
});
|
||||
} catch {
|
||||
dispatch({
|
||||
type: "LOAD_ERR",
|
||||
error: "تعذّر تحميل العناوين. يرجى المحاولة مجدداً.",
|
||||
});
|
||||
}
|
||||
}, [clientId]);
|
||||
const loadAddresses = useCallback(async () => {
|
||||
if (!clientId) return;
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const addresses = await clientAddressService.getAllNormalized(
|
||||
clientId,
|
||||
token,
|
||||
);
|
||||
dispatch({ type: "LOAD_OK", addresses });
|
||||
} catch {
|
||||
dispatch({
|
||||
type: "LOAD_ERR",
|
||||
error: "تعذّر تحميل العناوين. يرجى المحاولة مجدداً.",
|
||||
});
|
||||
}
|
||||
}, [clientId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAddresses();
|
||||
@@ -129,7 +120,7 @@ export function useClientAddresses(clientId: string) {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientAddressService.create(clientId, data, token);
|
||||
dispatch({ type: "ADD", address: normalizeAddress(res.data) });
|
||||
dispatch({ type: "ADD", address: normalizeAddress(res.data as any) });
|
||||
notify({ type: "success", message: "تم إضافة العنوان بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -148,7 +139,12 @@ export function useClientAddresses(clientId: string) {
|
||||
async (id: string, data: UpdateAddressFormValues): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientAddressService.update(clientId, id, data, token);
|
||||
const res = await clientAddressService.update(
|
||||
clientId,
|
||||
id,
|
||||
data,
|
||||
token,
|
||||
);
|
||||
dispatch({ type: "UPDATE", address: normalizeAddress(res.data) });
|
||||
notify({ type: "success", message: "تم تحديث العنوان." });
|
||||
return true;
|
||||
@@ -200,7 +196,8 @@ export function useClientAddresses(clientId: string) {
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "تعذّر تعيين العنوان كأساسي.",
|
||||
message:
|
||||
err instanceof Error ? err.message : "تعذّر تعيين العنوان كأساسي.",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
@@ -221,4 +218,4 @@ export function useClientAddresses(clientId: string) {
|
||||
settingPrimaryId,
|
||||
reload: loadAddresses,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,33 +71,16 @@ export function useClients() {
|
||||
}, []);
|
||||
|
||||
// ── Fetch clients ────────────────────────────────────────────────────────
|
||||
const loadClients = useCallback(async (p: number, q: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientService.getAll(p, q, token);
|
||||
|
||||
// Normalise both pagination shapes the backend might return
|
||||
const apiResponse = res as { data?: unknown };
|
||||
const payload = apiResponse.data ?? res;
|
||||
const response = payload as {
|
||||
data?: Client[];
|
||||
meta?: { total?: number; pages?: number };
|
||||
pagination?: { total?: number; pages?: number };
|
||||
};
|
||||
dispatch({
|
||||
type: "LOAD_OK",
|
||||
clients: response.data ?? [],
|
||||
total: response.meta?.total ?? response.pagination?.total ?? 0,
|
||||
pages: response.meta?.pages ?? response.pagination?.pages ?? 1,
|
||||
});
|
||||
} catch {
|
||||
dispatch({
|
||||
type: "LOAD_ERR",
|
||||
error: "تعذّر تحميل بيانات العملاء. يرجى المحاولة مجدداً.",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const loadClients = useCallback(async (p: number, q: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const { items, total, pages } = await clientService.getAll(p, q, token);
|
||||
dispatch({ type: "LOAD_OK", clients: items, total, pages });
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات العملاء. يرجى المحاولة مجدداً." });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reload whenever page or search changes
|
||||
useEffect(() => {
|
||||
@@ -111,24 +94,18 @@ export function useClients() {
|
||||
}, []);
|
||||
|
||||
// ── CREATE ───────────────────────────────────────────────────────────────
|
||||
const createClient = useCallback(
|
||||
async (data: ClientFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientService.create(data, token);
|
||||
dispatch({ type: "ADD", client: res.data });
|
||||
notify({ type: "success", message: "تم إضافة العميل بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "تعذّر إضافة العميل.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[notify],
|
||||
);
|
||||
const createClient = useCallback(async (data: ClientFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const client = await clientService.create(data, token);
|
||||
dispatch({ type: "ADD", client });
|
||||
notify({ type: "success", message: "تم إضافة العميل بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر إضافة العميل." });
|
||||
return false;
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
// ── UPDATE ───────────────────────────────────────────────────────────────
|
||||
const updateClient = useCallback(
|
||||
@@ -136,7 +113,7 @@ export function useClients() {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientService.update(id, data, token);
|
||||
dispatch({ type: "UPDATE", client: res.data });
|
||||
dispatch({ type: "UPDATE", client: res?.data });
|
||||
notify({ type: "success", message: "تم تحديث بيانات العميل." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -35,7 +35,6 @@ function reducer(s: TableState, a: TableAction): TableState {
|
||||
return { ...s, loading: false, error: a.error };
|
||||
case "DELETE":
|
||||
return { ...s, drivers: s.drivers.filter((d) => d.id !== a.id) };
|
||||
// Instantly reflect updated driver in the list without a full reload
|
||||
case "UPDATE":
|
||||
return { ...s, drivers: s.drivers.map((d) => (d.id === a.driver.id ? a.driver : d)) };
|
||||
case "CLEAR_ERR":
|
||||
@@ -60,7 +59,6 @@ export function useDrivers() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||
|
||||
// Show a toast for 4 seconds then auto-dismiss
|
||||
const notify = useCallback((n: ToastNotification) => {
|
||||
setNotification(n);
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
@@ -71,23 +69,8 @@ export function useDrivers() {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await driverService.getAll(p, q, token);
|
||||
const payload = (
|
||||
res as unknown as {
|
||||
data: {
|
||||
data: Driver[];
|
||||
pagination?: { total: number; pages: number };
|
||||
meta?: { total: number; pages: number };
|
||||
};
|
||||
}
|
||||
).data ?? res;
|
||||
|
||||
dispatch({
|
||||
type: "LOAD_OK",
|
||||
drivers: payload.data ?? [],
|
||||
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
|
||||
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
|
||||
});
|
||||
const { items, total, pages } = await driverService.getAll(p, q, token);
|
||||
dispatch({ type: "LOAD_OK", drivers: items, total, pages });
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات السائقين. يرجى المحاولة مجدداً." });
|
||||
}
|
||||
@@ -111,8 +94,7 @@ export function useDrivers() {
|
||||
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
|
||||
|
||||
if (hasFiles) {
|
||||
const res = await driverService.createWithImages(payload, token);
|
||||
const created = (res as unknown as { data: Driver }).data;
|
||||
const created = await driverService.createWithImages(payload, token);
|
||||
if (created?.id) {
|
||||
await driverService.getById(created.id, token).catch(() => null);
|
||||
}
|
||||
@@ -124,10 +106,9 @@ export function useDrivers() {
|
||||
await loadDrivers(page, search);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// ✅ بنعرض رسالة الخطأ كـ toast برضو، مش بس نرميها لفوق
|
||||
const message = err instanceof Error ? err.message : "تعذّر إضافة السائق. يرجى المحاولة لاحقاً.";
|
||||
notify({ type: "error", message });
|
||||
throw err; // يفضل يترمي عشان DriverFormModal يعرضه جوه المودال كمان لو محتاج
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[notify, loadDrivers, page, search],
|
||||
@@ -147,22 +128,14 @@ export function useDrivers() {
|
||||
const token = getStoredToken();
|
||||
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
|
||||
|
||||
let updatedDriver: Driver;
|
||||
|
||||
if (hasFiles) {
|
||||
await driverService.updateWithImages(id, payload, token);
|
||||
const fresh = await driverService.getById(id, token);
|
||||
updatedDriver = (fresh as unknown as { data: Driver }).data;
|
||||
} else {
|
||||
const res = await driverService.update(id, payload, token);
|
||||
updatedDriver = (res as unknown as { data: Driver }).data;
|
||||
}
|
||||
const updatedDriver = hasFiles
|
||||
? await driverService.updateWithImages(id, payload, token)
|
||||
: await driverService.update(id, payload, token);
|
||||
|
||||
dispatch({ type: "UPDATE", driver: updatedDriver });
|
||||
notify({ type: "success", message: "تم تحديث بيانات السائق بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
// ✅ نفس الفكرة هنا — أي فشل في التحديث يبان كـ toast أحمر
|
||||
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق. يرجى المحاولة لاحقاً.";
|
||||
notify({ type: "error", message });
|
||||
throw err;
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { ToastNotification } from "@/src/Components/UI";
|
||||
export type OrderNotification = ToastNotification;
|
||||
|
||||
// ── Table state / reducer ──────────────────────────────────────────────────
|
||||
// Mirrors useDriver.ts's TableState/TableAction shape so the two resource
|
||||
// hooks stay readable side by side.
|
||||
interface TableState {
|
||||
orders: Order[];
|
||||
loading: boolean;
|
||||
@@ -41,7 +39,6 @@ function reducer(s: TableState, a: TableAction): TableState {
|
||||
return { ...s, loading: false, error: a.error };
|
||||
case "DELETE":
|
||||
return { ...s, orders: s.orders.filter((o) => o.id !== a.id) };
|
||||
// Instantly reflect the updated order in the list without a full reload
|
||||
case "UPDATE":
|
||||
return { ...s, orders: s.orders.map((o) => (o.id === a.order.id ? a.order : o)) };
|
||||
case "CLEAR_ERR":
|
||||
@@ -67,7 +64,6 @@ export function useOrders() {
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||
|
||||
// Show a toast for 4 seconds then auto-dismiss — same timing as useDriver.ts
|
||||
const notify = useCallback((n: ToastNotification) => {
|
||||
setNotification(n);
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
@@ -77,20 +73,14 @@ export function useOrders() {
|
||||
const loadOrders = useCallback(async (p: number, q: string, status: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const res = await orderService.getAll({
|
||||
const { items, total, pages } = await orderService.getAll({
|
||||
page: p,
|
||||
limit: 12,
|
||||
...(q ? { search: q } : {}),
|
||||
...(status ? { currentStatus: status } : {}),
|
||||
});
|
||||
const payload = (res as unknown as { data: { data: Order[]; meta: { total: number; totalPages: number } } }).data ?? res;
|
||||
|
||||
dispatch({
|
||||
type: "LOAD_OK",
|
||||
orders: payload.data ?? [],
|
||||
total: payload.meta?.total ?? 0,
|
||||
pages: payload.meta?.totalPages ?? 1,
|
||||
});
|
||||
dispatch({ type: "LOAD_OK", orders: items, total, pages });
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات الطلبات. يرجى المحاولة مجدداً." });
|
||||
}
|
||||
@@ -109,10 +99,9 @@ export function useOrders() {
|
||||
await loadOrders(page, search, statusFilter);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// نعرض رسالة الخطأ كـ toast برضو، مش بس نرميها لفوق
|
||||
const message = err instanceof Error ? err.message : "تعذّر إنشاء الطلب. يرجى المحاولة لاحقاً.";
|
||||
notify({ type: "error", message });
|
||||
throw err; // يفضل يترمي عشان OrderFormModal يعرضه جوه المودال كمان لو محتاج
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[notify, loadOrders, page, search, statusFilter],
|
||||
@@ -122,8 +111,7 @@ export function useOrders() {
|
||||
const updateOrder = useCallback(
|
||||
async (id: string, payload: UpdateOrderPayload): Promise<boolean> => {
|
||||
try {
|
||||
const res = await orderService.update(id, payload);
|
||||
const updated = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
||||
const updated = await orderService.update(id, payload);
|
||||
dispatch({ type: "UPDATE", order: updated });
|
||||
notify({ type: "success", message: "تم تحديث الطلب بنجاح." });
|
||||
return true;
|
||||
@@ -140,8 +128,7 @@ export function useOrders() {
|
||||
const updateStatus = useCallback(
|
||||
async (id: string, payload: UpdateOrderStatusPayload): Promise<boolean> => {
|
||||
try {
|
||||
const res = await orderService.updateStatus(id, payload);
|
||||
const updated = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
||||
const updated = await orderService.updateStatus(id, payload);
|
||||
dispatch({ type: "UPDATE", order: updated });
|
||||
notify({ type: "success", message: "تم تحديث حالة الطلب بنجاح." });
|
||||
return true;
|
||||
|
||||
@@ -82,33 +82,20 @@ export function useRoles() {
|
||||
|
||||
// ── Fetch roles ─────────────────────────────────────────────────────────────
|
||||
const loadRoles = useCallback(async (p: number, q: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await roleService.getAll(p, q, token);
|
||||
const payload =
|
||||
(
|
||||
res as unknown as {
|
||||
data: {
|
||||
data: Role[];
|
||||
meta?: { total: number; pages: number };
|
||||
pagination?: { total: number; pages: number };
|
||||
};
|
||||
}
|
||||
).data ?? res;
|
||||
dispatch({
|
||||
type: "LOAD_OK",
|
||||
roles: payload.data ?? [],
|
||||
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
|
||||
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
|
||||
});
|
||||
} catch {
|
||||
dispatch({
|
||||
type: "LOAD_ERR",
|
||||
error: "تعذّر تحميل بيانات الأدوار. يرجى المحاولة مجدداً.",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const { items, total, pages } = await roleService.getAll(p, q, token);
|
||||
dispatch({ type: "LOAD_OK", roles: items, total, pages });
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات الأدوار. يرجى المحاولة مجدداً." });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
roleService.getPermissions(token).then(setPermissions).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Load permissions on mount ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -131,36 +118,24 @@ export function useRoles() {
|
||||
|
||||
// ── CRUD actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const createRole = useCallback(
|
||||
async (data: RoleFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await roleService.create(data, token);
|
||||
const role = (res as unknown as { data: Role }).data;
|
||||
|
||||
// Endpoint 2 (PATCH /role/{id}/permissions/bulk): attach selected
|
||||
// permissions right after creation, since POST /role only accepts
|
||||
// name/description.
|
||||
if (data.permissionIds.length) {
|
||||
await roleService.bulkAssignPermissions(role.id, data.permissionIds, token);
|
||||
}
|
||||
|
||||
await loadRoles(page, search);
|
||||
notify({
|
||||
type: "success",
|
||||
message: "تم إنشاء الدور وتعيين الصلاحيات بنجاح.",
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: "error",
|
||||
message: parseApiError(err, "تعذّر إنشاء الدور."),
|
||||
});
|
||||
return false;
|
||||
const createRole = useCallback(
|
||||
async (data: RoleFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const role = await roleService.create(data, token);
|
||||
if (data.permissionIds.length) {
|
||||
await roleService.bulkAssignPermissions(role.id, data.permissionIds, token);
|
||||
}
|
||||
},
|
||||
[page, search, loadRoles, notify],
|
||||
);
|
||||
await loadRoles(page, search);
|
||||
notify({ type: "success", message: "تم إنشاء الدور وتعيين الصلاحيات بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: parseApiError(err, "تعذّر إنشاء الدور.") });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[page, search, loadRoles, notify],
|
||||
);
|
||||
|
||||
const updateRole = useCallback(
|
||||
async (
|
||||
|
||||
@@ -102,36 +102,22 @@ export function useTrips() {
|
||||
|
||||
// ── Fetch trips ───────────────────────────────────────────────────────────
|
||||
|
||||
const loadTrips = useCallback(
|
||||
async (p: number, q: string, st: TripStatus | "") => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await tripService.getAll(
|
||||
{ page: p, limit: 12, search: q || undefined, status: st || undefined },
|
||||
token,
|
||||
);
|
||||
const payload = (
|
||||
res as unknown as {
|
||||
data: { data: Trip[]; pagination?: { total: number; totalPages: number } };
|
||||
}
|
||||
).data ?? res;
|
||||
|
||||
dispatch({
|
||||
type: "LOAD_OK",
|
||||
trips: payload.data ?? [],
|
||||
total: payload.pagination?.total ?? 0,
|
||||
pages: payload.pagination?.totalPages ?? 1,
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: "LOAD_ERR",
|
||||
error: extractApiMessage(err, "تعذّر تحميل بيانات الرحلات. يرجى المحاولة مجدداً."),
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const loadTrips = useCallback(
|
||||
async (p: number, q: string, st: TripStatus | "") => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const { items, total, pages } = await tripService.getAll(
|
||||
{ page: p, limit: 12, search: q || undefined, status: st || undefined },
|
||||
token,
|
||||
);
|
||||
dispatch({ type: "LOAD_OK", trips: items, total, pages });
|
||||
} catch (err) {
|
||||
dispatch({ type: "LOAD_ERR", error: extractApiMessage(err, "تعذّر تحميل بيانات الرحلات. يرجى المحاولة مجدداً.") });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadTrips(page, search, status);
|
||||
@@ -139,42 +125,38 @@ export function useTrips() {
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────────
|
||||
|
||||
const createTrip = useCallback(
|
||||
async (payload: CreateTripPayload): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await tripService.create(payload, token);
|
||||
const newTrip = (res as unknown as { data: Trip }).data;
|
||||
dispatch({ type: "ADD", trip: newTrip });
|
||||
notify({ type: "success", message: "تم إضافة الرحلة بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر إضافة الرحلة.") });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[notify],
|
||||
);
|
||||
|
||||
const createTrip = useCallback(
|
||||
async (payload: CreateTripPayload): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const newTrip = await tripService.create(payload, token);
|
||||
dispatch({ type: "ADD", trip: newTrip });
|
||||
notify({ type: "success", message: "تم إضافة الرحلة بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر إضافة الرحلة.") });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[notify],
|
||||
);
|
||||
// ── Update ────────────────────────────────────────────────────────────────
|
||||
|
||||
const updateTrip = useCallback(
|
||||
async (id: string, payload: UpdateTripPayload): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await tripService.update(id, payload, token);
|
||||
const updated = (res as unknown as { data: Trip }).data;
|
||||
dispatch({ type: "UPDATE", trip: updated });
|
||||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[notify],
|
||||
);
|
||||
|
||||
const updateTrip = useCallback(
|
||||
async (id: string, payload: UpdateTripPayload): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const updated = await tripService.update(id, payload, token);
|
||||
dispatch({ type: "UPDATE", trip: updated });
|
||||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[notify],
|
||||
);
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
const deleteTrip = useCallback(
|
||||
|
||||
@@ -49,33 +49,22 @@ export function useUsers() {
|
||||
}, []);
|
||||
|
||||
// fetch users with pagination and search
|
||||
const loadUsers = useCallback(async (p: number, q: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await userService.getAll(p, q, token);
|
||||
const payload = res.data ?? res;
|
||||
dispatch({
|
||||
type: "LOAD_OK",
|
||||
users: payload.data ?? [],
|
||||
total: payload.meta?.total ?? 0,
|
||||
pages: payload.meta?.pages ?? 1,
|
||||
});
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "عذراً، حدث خطأ أثناء تحميل بيانات المستخدمين. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// fetch roles and branches for form dropdowns on mount
|
||||
useEffect(() => {
|
||||
const loadUsers = useCallback(async (p: number, q: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
userService.getRoles(token)
|
||||
.then(res => setRoles((res.data ?? res).data ?? []))
|
||||
.catch(() => {});
|
||||
userService.getBranches(token)
|
||||
.then(res => setBranches((res.data ?? res).data ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const { items, total, pages } = await userService.getAll(p, q, token);
|
||||
dispatch({ type: "LOAD_OK", users: items, total, pages });
|
||||
} catch {
|
||||
dispatch({ type: "LOAD_ERR", error: "عذراً، حدث خطأ أثناء تحميل بيانات المستخدمين. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
userService.getRoles(token).then(setRoles).catch(() => {});
|
||||
userService.getBranches(token).then(setBranches).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// reload users when page or search changes
|
||||
useEffect(() => {
|
||||
@@ -83,28 +72,25 @@ export function useUsers() {
|
||||
}, [page, search, loadUsers]);
|
||||
|
||||
// create new user
|
||||
const createUser = useCallback(async (data: UserFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await userService.create(data as UserFormData & { password: string }, token);
|
||||
dispatch({ type: "ADD", user: res.data });
|
||||
notify({ type: "success", message: "تم إنشاء مستخدم جديد بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error && err.message
|
||||
? err.message
|
||||
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
|
||||
notify({ type: "error", message: msg });
|
||||
return false;
|
||||
}
|
||||
}, [notify]);
|
||||
const createUser = useCallback(async (data: UserFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const user = await userService.create(data as UserFormData & { password: string }, token);
|
||||
dispatch({ type: "ADD", user });
|
||||
notify({ type: "success", message: "تم إنشاء مستخدم جديد بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: err instanceof Error && err.message ? err.message : "تعذر الاتصال بالخادم." });
|
||||
return false;
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
// update existing user
|
||||
const updateUser = useCallback(async (id: string, data: UserFormData): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await userService.update(id, data, token);
|
||||
dispatch({ type: "UPDATE", user: res.data });
|
||||
dispatch({ type: "UPDATE", user: res?.data });
|
||||
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,4 +16,8 @@ export const archivedBranchService = {
|
||||
`branches/archived${buildArchivedQuery(page, search)}`,
|
||||
token,
|
||||
),
|
||||
getAllUnwrapped: async (page, search, token) => {
|
||||
const res = await archivedBranchService.getAll(page, search, token);
|
||||
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||
},
|
||||
};
|
||||
@@ -28,7 +28,15 @@ export const archivedClientService = {
|
||||
`client/${clientId}/orders/archived?page=${page}&limit=10`,
|
||||
token,
|
||||
),
|
||||
|
||||
getAllUnwrapped: async (page, search, token) => {
|
||||
const res = await archivedClientService.getAll(page, search, token);
|
||||
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||
},
|
||||
getByIdUnwrapped: async (id, token) => (await archivedClientService.getById(id, token)).data,
|
||||
getArchivedOrdersUnwrapped: async (clientId, page, token) => {
|
||||
const res = await archivedClientService.getArchivedOrders(clientId, page, token);
|
||||
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||
},
|
||||
// NOTE: delete/restore intentionally left out for now — mirrors
|
||||
// archivedUser.service.ts, see ticket follow-up.
|
||||
};
|
||||
@@ -1,16 +1,8 @@
|
||||
import { get } from "../api";
|
||||
import type { ArchivedClientAddressListResponse } from "@/src/types/client_adresses";
|
||||
import type { ArchivedClientAddress, 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.
|
||||
getAll: (token: string | null) => get<ArchivedClientAddressListResponse>("addresses/archived", token),
|
||||
getAllUnwrapped: async (token: string | null): Promise<ArchivedClientAddress[]> =>
|
||||
(await archivedClientAddressService.getAll(token)).data,
|
||||
};
|
||||
@@ -43,4 +43,10 @@ export const archivedDriverService = {
|
||||
*/
|
||||
getStatusHistory: (id: string, token: string | null) =>
|
||||
get<ArchivedDriverStatusHistoryResponse>(`driver/archived/driverStatus/${id}`, token),
|
||||
getAllUnwrapped: async (page, search, token) => {
|
||||
const res = await archivedDriverService.getAll(page, search, token);
|
||||
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||
},
|
||||
getByIdUnwrapped: async (id, token) => (await archivedDriverService.getById(id, token)).data,
|
||||
getStatusHistoryUnwrapped: async (id, token) => (await archivedDriverService.getStatusHistory(id, token)).data ?? [],
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { get } from "../api";
|
||||
import type { ArchivedOrderListResponse } from "@/src/types/order";
|
||||
import type { ArchivedOrder, ArchivedOrderListResponse } from "@/src/types/order";
|
||||
|
||||
export const archivedOrderService = {
|
||||
/** Fetching the full archived order list (endpoint returns no pagination meta) */
|
||||
getAll: (token: string | null) =>
|
||||
get<ArchivedOrderListResponse>("orders/archived", token),
|
||||
getAll: (token: string | null) => get<ArchivedOrderListResponse>("orders/archived", token),
|
||||
getAllUnwrapped: async (token: string | null): Promise<ArchivedOrder[]> => {
|
||||
const res = await archivedOrderService.getAll(token);
|
||||
return Array.isArray(res.data?.data) ? res.data.data : [];
|
||||
},
|
||||
};
|
||||
@@ -1,19 +1,21 @@
|
||||
import { get } from "../api";
|
||||
import type {
|
||||
ArchivedRole,
|
||||
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),
|
||||
getAll: (token: string | null) => get<ArchivedRoleListResponse>("role/archived", token),
|
||||
|
||||
getAllUnwrapped: async (token: string | null): Promise<ArchivedRole[]> => {
|
||||
const res = await archivedRoleService.getAll(token);
|
||||
return Array.isArray(res.data?.data) ? res.data.data : [];
|
||||
},
|
||||
|
||||
/** 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.
|
||||
getByIdUnwrapped: async (id: string, token: string | null): Promise<ArchivedRole> =>
|
||||
(await archivedRoleService.getById(id, token)).data,
|
||||
};
|
||||
@@ -17,4 +17,9 @@ export const archivedTripService = {
|
||||
/** Get a single archived trip by id */
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<ArchivedTripResponse>(`trip/archived/${id}`, token),
|
||||
getAllUnwrapped: async (page, search, token) => {
|
||||
const res = await archivedTripService.getAll(page, search, token);
|
||||
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||
},
|
||||
getByIdUnwrapped: async (id, token) => (await archivedTripService.getById(id, token)).data,
|
||||
};
|
||||
@@ -10,16 +10,16 @@ function buildArchivedQuery(page: number, search: string): string {
|
||||
}
|
||||
|
||||
export const archivedUserService = {
|
||||
/** Fetching the archived user list, paginated */
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ArchivedUserListResponse>(
|
||||
`users/archived${buildArchivedQuery(page, search)}`,
|
||||
token,
|
||||
),
|
||||
get<ArchivedUserListResponse>(`users/archived${buildArchivedQuery(page, search)}`, token),
|
||||
|
||||
/** Get a single archived user by id */
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<ArchivedUserResponse>(`users/archived/${id}`, token),
|
||||
getAllUnwrapped: async (page: number, search: string, token: string | null) => {
|
||||
const res = await archivedUserService.getAll(page, search, token);
|
||||
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||
},
|
||||
|
||||
// NOTE: delete/restore intentionally left out for now — see ticket follow-up.
|
||||
getById: async (id: string, token: string | null) => {
|
||||
const res = await get<ArchivedUserResponse>(`users/archived/${id}`, token);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -1,56 +1,64 @@
|
||||
|
||||
import { get, post, patch, del } from "./api";
|
||||
import type { ApiListResponse, Branch, BranchDetail, BranchFormData, BranchResponse } from "@/src/types/branch";
|
||||
import type {
|
||||
ApiListResponse, Branch, BranchDetail, BranchFormData, BranchResponse,
|
||||
BranchListResult, BranchOption,
|
||||
} from "@/src/types/branch";
|
||||
|
||||
|
||||
|
||||
|
||||
/** Building a query string to fetch branches with search and pagination*/
|
||||
function buildBranchesQuery(page: number, search: string): string {
|
||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
}
|
||||
|
||||
export const branchService = {
|
||||
/** Bring up the list of branches with the ability to search and browse between pages*/
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ApiListResponse<Branch>>(`branches${buildBranchesQuery(page, search)}`, token),
|
||||
getAll: async (page: number, search: string, token: string | null): Promise<BranchListResult> => {
|
||||
const res: ApiListResponse<Branch> = await get<ApiListResponse<Branch>>(
|
||||
`branches${buildBranchesQuery(page, search)}`,
|
||||
token,
|
||||
);
|
||||
return {
|
||||
items: res.data.data,
|
||||
total: res.data.meta?.total ?? res.data.pagination?.total ?? 0,
|
||||
pages: res.data.meta?.pages ?? res.data.pagination?.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
/** create new branch*/
|
||||
create: (data: BranchFormData, token: string | null) =>
|
||||
post<BranchResponse>("branches", buildPayload(data), token),
|
||||
create: async (data: BranchFormData, token: string | null): Promise<Branch> => {
|
||||
const res: BranchResponse = await post<BranchResponse>("branches", buildPayload(data), token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** update branch*/
|
||||
update: (id: string, data: Partial<BranchFormData>, token: string | null) =>
|
||||
patch<BranchResponse>(`branches/${id}`, buildPayload(data), token),
|
||||
update: async (id: string, data: Partial<BranchFormData>, token: string | null): Promise<Branch> => {
|
||||
const res: BranchResponse = await patch<BranchResponse>(`branches/${id}`, buildPayload(data), token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**delete branch*/
|
||||
delete: (id: string, token: string | null) => del<void>(`branches/${id}`, token),
|
||||
|
||||
/** get branch by id*/
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<{ data: BranchDetail }>(`branches/${id}`, token),
|
||||
getById: async (id: string, token: string | null): Promise<BranchDetail> => {
|
||||
const res = await get<{ data: BranchDetail }>(`branches/${id}`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** Dropdown helper — replaces raw `get<{data:{data:Branch[]}}>("branches?limit=100")` in form modals. */
|
||||
getOptions: async (token: string | null): Promise<BranchOption[]> => {
|
||||
const res = await get<{ data: { data: BranchOption[] } }>("branches?limit=100", token);
|
||||
return res.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
/** Converting the form data into a payload suitable for the API*/
|
||||
function buildPayload(
|
||||
data: Partial<BranchFormData>,
|
||||
): Record<string, string | number> {
|
||||
function buildPayload(data: Partial<BranchFormData>): Record<string, string | number> {
|
||||
const payload: Record<string, string | number> = {};
|
||||
|
||||
if (data.name !== undefined) payload.name = data.name.trim();
|
||||
if (data.email) payload.email = data.email.trim();
|
||||
if (data.phone) payload.phone = data.phone.trim();
|
||||
if (data.country) payload.country = data.country.trim();
|
||||
if (data.email) payload.email = data.email.trim();
|
||||
if (data.phone) payload.phone = data.phone.trim();
|
||||
if (data.country) payload.country = data.country.trim();
|
||||
if (data.city !== undefined) payload.city = data.city.trim();
|
||||
if (data.state) payload.state = data.state.trim();
|
||||
if (data.district) payload.district = data.district.trim();
|
||||
if (data.state) payload.state = data.state.trim();
|
||||
if (data.district) payload.district = data.district.trim();
|
||||
if (data.street !== undefined) payload.street = data.street.trim();
|
||||
if (data.buildingNo) payload.buildingNo = data.buildingNo.trim();
|
||||
if (data.unitNo) payload.unitNo = data.unitNo.trim();
|
||||
if (data.zipCode) payload.zipCode = data.zipCode.trim();
|
||||
|
||||
if (data.latitude && !isNaN(Number(data.latitude))) payload.latitude = Number(data.latitude);
|
||||
if (data.buildingNo) payload.buildingNo = data.buildingNo.trim();
|
||||
if (data.unitNo) payload.unitNo = data.unitNo.trim();
|
||||
if (data.zipCode) payload.zipCode = data.zipCode.trim();
|
||||
if (data.latitude && !isNaN(Number(data.latitude))) payload.latitude = Number(data.latitude);
|
||||
if (data.longitude && !isNaN(Number(data.longitude))) payload.longitude = Number(data.longitude);
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { get, post, patch, del } from "./api";
|
||||
import type {
|
||||
Car,
|
||||
CarListResponse,
|
||||
CarDetailResponse,
|
||||
CarImageListResponse,
|
||||
CreateCarPayload,
|
||||
UpdateCarPayload,
|
||||
Car, CarImage, CarListResponse, CarDetailResponse, CarImageListResponse,
|
||||
CarListResult, CarOption, CreateCarPayload, UpdateCarPayload,
|
||||
} from "@/src/types/car";
|
||||
|
||||
/** Build a query string for list endpoints */
|
||||
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
||||
if (!entries.length) return "";
|
||||
@@ -16,91 +11,64 @@ function buildQuery(params: Record<string, string | number | undefined>): string
|
||||
}
|
||||
|
||||
export const carService = {
|
||||
/**
|
||||
* GET /cars
|
||||
* Fetch paginated + searchable car list.
|
||||
*/
|
||||
getAll: (
|
||||
page = 1,
|
||||
search = "",
|
||||
token: string | null,
|
||||
) =>
|
||||
get<CarListResponse>(
|
||||
getAll: async (page = 1, search = "", token: string | null): Promise<CarListResult> => {
|
||||
const res: CarListResponse = await get<CarListResponse>(
|
||||
`cars${buildQuery({ page, limit: 10, search: search || undefined })}`,
|
||||
token,
|
||||
),
|
||||
);
|
||||
return {
|
||||
items: res.data.data,
|
||||
total: res.data.meta?.total ?? res.data.pagination?.total ?? 0,
|
||||
pages: res.data.meta?.pages ?? res.data.pagination?.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /cars/archived
|
||||
* Fetch soft-deleted cars.
|
||||
*/
|
||||
getArchived: (token: string | null) =>
|
||||
get<CarListResponse>("cars/archived", token),
|
||||
getArchived: async (token: string | null): Promise<Car[]> => {
|
||||
const res: CarListResponse = await get<CarListResponse>("cars/archived", token);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /cars/:id
|
||||
* Fetch a single car with status history.
|
||||
*/
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<CarDetailResponse>(`cars/${id}`, token),
|
||||
getById: async (id: string, token: string | null): Promise<Car> => {
|
||||
const res: CarDetailResponse = await get<CarDetailResponse>(`cars/${id}`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /cars
|
||||
* Create a new car record.
|
||||
*/
|
||||
create: (payload: CreateCarPayload, token: string | null) =>
|
||||
post<{ data: Car }>("cars", payload, token),
|
||||
create: async (payload: CreateCarPayload, token: string | null): Promise<Car> => {
|
||||
const res = await post<{ data: Car }>("cars", payload, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /cars/:id
|
||||
* Update an existing car's details.
|
||||
*/
|
||||
update: (id: string, payload: UpdateCarPayload, token: string | null) =>
|
||||
patch<{ data: Car }>(`cars/${id}`, payload, token),
|
||||
update: async (id: string, payload: UpdateCarPayload, token: string | null): Promise<Car> => {
|
||||
const res = await patch<{ data: Car }>(`cars/${id}`, payload, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /cars/:id
|
||||
* Soft-delete a car (returns 204 No Content).
|
||||
*/
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`cars/${id}`, token),
|
||||
delete: (id: string, token: string | null) => del<void>(`cars/${id}`, token),
|
||||
|
||||
// ── Images ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /car-images/car/:id
|
||||
* Fetch all active images for a car.
|
||||
* Supports optional query params: day, month, year, date, sortBy.
|
||||
*/
|
||||
getImages: (
|
||||
getImages: async (
|
||||
carId: string,
|
||||
token: string | null,
|
||||
filters: { day?: string; month?: string; year?: string; date?: string; sortBy?: "asc" | "desc" } = {},
|
||||
) =>
|
||||
get<CarImageListResponse>(
|
||||
): Promise<CarImage[]> => {
|
||||
const res: CarImageListResponse = await get<CarImageListResponse>(
|
||||
`car-images/car/${carId}${buildQuery(filters as Record<string, string>)}`,
|
||||
token,
|
||||
),
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /car-images/car/:id/archive
|
||||
* Fetch soft-deleted images for a car.
|
||||
*/
|
||||
getArchivedImages: (carId: string, token: string | null) =>
|
||||
get<CarImageListResponse>(`car-images/car/${carId}/archive`, token),
|
||||
getArchivedImages: async (carId: string, token: string | null): Promise<CarImage[]> => {
|
||||
const res: CarImageListResponse = await get<CarImageListResponse>(`car-images/car/${carId}/archive`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /car-images/car/:id (multipart/form-data)
|
||||
* Upload one or more images.
|
||||
* Uses raw fetch because multer expects FormData, not JSON.
|
||||
*/
|
||||
uploadImages: async (
|
||||
carId: string,
|
||||
files: File[],
|
||||
stage: "BEFORE" | "AFTER" | "GENERAL" = "GENERAL",
|
||||
token: string | null,
|
||||
maintenanceId?: string,
|
||||
): Promise<CarImageListResponse> => {
|
||||
): Promise<CarImage[]> => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append("images", f));
|
||||
form.append("stage", stage);
|
||||
@@ -112,18 +80,19 @@ export const carService = {
|
||||
body: form,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null);
|
||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<CarImageListResponse>;
|
||||
const data = (await res.json()) as CarImageListResponse;
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /car-images/:imageId
|
||||
* Soft-delete a single image.
|
||||
*/
|
||||
deleteImage: (imageId: string, token: string | null) =>
|
||||
del<void>(`car-images/${imageId}`, token),
|
||||
deleteImage: (imageId: string, token: string | null) => del<void>(`car-images/${imageId}`, token),
|
||||
|
||||
/** Dropdown helper — replaces raw get<T>() calls in TripFormModal / CarFormModal. */
|
||||
getActiveOptions: async (token: string | null): Promise<CarOption[]> => {
|
||||
const res = await get<{ data: { data: CarOption[] } }>("cars?limit=100¤tStatus=Active", token);
|
||||
return res.data.data;
|
||||
},
|
||||
};
|
||||
@@ -95,8 +95,19 @@ export const carMaintenanceService = {
|
||||
*/
|
||||
getAllArchivedGlobal: (token: string | null) =>
|
||||
get<MaintenanceListResponse>("maintenance/archived", token),
|
||||
|
||||
getAllUnwrapped: async (carId: string, token: string | null): Promise<CarMaintenance[]> => {
|
||||
const res = await carMaintenanceService.getAll(carId, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getArchivedUnwrapped: async (carId: string, token: string | null): Promise<CarMaintenance[]> => {
|
||||
const res = await carMaintenanceService.getArchived(carId, token);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Re-exported so callers can build extra filters (day/month/year, search…)
|
||||
// the same way carService does, without duplicating the helper.
|
||||
export { buildQuery as buildMaintenanceQuery };
|
||||
@@ -1,42 +1,42 @@
|
||||
|
||||
// Mirrors user.service.ts exactly: token parameter, buildPayload, buildQuery.
|
||||
import { get, post, put, del } from "./api";
|
||||
import type { ApiResponse, ApiListResponse, Client, ClientFormData, ClientResponse } from "@/src/types/client";
|
||||
import type { ApiResponse, ApiListResponse, Client, ClientFormData, ClientResponse, ClientListResult } from "@/src/types/client";
|
||||
|
||||
|
||||
|
||||
|
||||
/** بناء query string لجلب العملاء مع البحث والصفحات */
|
||||
function buildClientsQuery(page: number, search: string): string {
|
||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
}
|
||||
|
||||
export const clientService = {
|
||||
/** جلب قائمة العملاء مع إمكانية البحث والتصفح بين الصفحات */
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ApiListResponse<Client>>(`client${buildClientsQuery(page, search)}`, token),
|
||||
getAll: async (page: number, search: string, token: string | null): Promise<ClientListResult> => {
|
||||
const res: ApiListResponse<Client> = await get<ApiListResponse<Client>>(
|
||||
`client${buildClientsQuery(page, search)}`, token,
|
||||
);
|
||||
return {
|
||||
items: res.data.data,
|
||||
total: res.data.meta?.total ?? res.data.pagination?.total ?? 0,
|
||||
pages: res.data.meta?.pages ?? res.data.pagination?.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
/** جلب عميل واحد بالـ ID (مع العناوين المرفقة) */
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<ApiResponse<Client>>(`client/${id}`, token),
|
||||
getById: async (id: string, token: string | null): Promise<Client> => {
|
||||
const res: ApiResponse<Client> = await get<ApiResponse<Client>>(`client/${id}`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** إنشاء عميل جديد */
|
||||
create: (data: ClientFormData, token: string | null) =>
|
||||
post<ClientResponse>("client", buildPayload(data), token),
|
||||
create: async (data: ClientFormData, token: string | null): Promise<Client> => {
|
||||
const res: ClientResponse = await post<ClientResponse>("client", buildPayload(data), token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** تعديل بيانات عميل موجود */
|
||||
update: (id: string, data: ClientFormData, token: string | null) =>
|
||||
put<ClientResponse>(`client/${id}`, buildPayload(data), token),
|
||||
update: async (id: string, data: ClientFormData, token: string | null): Promise<Client> => {
|
||||
const res: ClientResponse = await put<ClientResponse>(`client/${id}`, buildPayload(data), token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** حذف عميل */
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`client/${id}`, token),
|
||||
delete: (id: string, token: string | null) => del<void>(`client/${id}`, token),
|
||||
};
|
||||
|
||||
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
|
||||
function buildPayload(data: ClientFormData): Record<string, string | boolean> {
|
||||
const payload: Record<string, string | boolean> = {};
|
||||
|
||||
if (data.name?.trim()) payload.name = data.name.trim();
|
||||
if (data.email?.trim()) payload.email = data.email.trim();
|
||||
if (data.phone?.trim()) payload.phone = data.phone.trim();
|
||||
@@ -44,6 +44,5 @@ function buildPayload(data: ClientFormData): Record<string, string | boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
UpdateAddressFormValues,
|
||||
} from "@/src/validations/client_address.validator";
|
||||
|
||||
function normalizeAddress(raw: { id?: string; _id?: string; [k: string]: unknown }): ClientAddress {
|
||||
return { ...raw, id: raw.id ?? raw._id ?? "" } as ClientAddress;
|
||||
}
|
||||
|
||||
|
||||
const collectionBase = (clientId: string) => {
|
||||
@@ -73,6 +76,19 @@ export const clientAddressService = {
|
||||
setPrimary: (addressId: string, token: string | null) =>
|
||||
patch<ApiResponse<ClientAddress>>(`${addressBase(addressId)}/primary`, {}, token),
|
||||
|
||||
getAllNormalized: async (clientId: string, token: string | null): Promise<ClientAddress[]> => {
|
||||
const res = await get<ApiResponse<ClientAddress[]> | { data: { data: ClientAddress[] } }>(
|
||||
collectionBase(clientId), token,
|
||||
);
|
||||
const payload = res.data;
|
||||
const rawList: unknown[] = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray((payload as { data?: unknown })?.data)
|
||||
? (payload as { data: unknown[] }).data
|
||||
: [];
|
||||
return rawList.map((r) => normalizeAddress(r as { id?: string; _id?: string }));
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -87,4 +103,6 @@ function buildPayload(
|
||||
details: data.details,
|
||||
location: data.location,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default { normalizeAddress };
|
||||
@@ -4,6 +4,9 @@ import type {
|
||||
DriverListResponse,
|
||||
DriverDetailResponse,
|
||||
DriverReportResponse,
|
||||
DriverListResult,
|
||||
DriverReportResult,
|
||||
DriverOption,
|
||||
CreateDriverPayload,
|
||||
UpdateDriverPayload,
|
||||
} from "@/src/types/driver";
|
||||
@@ -15,10 +18,6 @@ function buildQuery(params: Record<string, string | number | undefined>): string
|
||||
}
|
||||
|
||||
// ── Image URL normaliser ──────────────────────────────────────────────────────
|
||||
// Returns the URL as-is — <img> tags are not subject to CORS, so we can point
|
||||
// directly at the backend without a Next.js proxy hop.
|
||||
// Only strips query-string noise or fixes obviously malformed paths if needed.
|
||||
|
||||
function normaliseImageUrl(url: string | null | undefined): string | null {
|
||||
if (!url) return null;
|
||||
return url;
|
||||
@@ -29,7 +28,7 @@ function mapDriverImages<
|
||||
photoUrl?: string | null;
|
||||
nationalPhotoUrl?: string | null;
|
||||
driverCardPhotoUrl?: string | null;
|
||||
},
|
||||
}
|
||||
>(driver: T): T {
|
||||
return {
|
||||
...driver,
|
||||
@@ -39,11 +38,7 @@ function mapDriverImages<
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Image compression ─────────────────────────────────────────────────────────
|
||||
// Resizes to max 1024px while preserving the original file type and name.
|
||||
|
||||
async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<File> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
@@ -54,13 +49,11 @@ async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<
|
||||
|
||||
let { width, height } = img;
|
||||
|
||||
// Guard: malformed/zero-dimension image — bail out to original file
|
||||
if (!width || !height) {
|
||||
resolve(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only resize if actually oversized
|
||||
if (width <= maxPx && height <= maxPx) {
|
||||
resolve(file);
|
||||
return;
|
||||
@@ -81,9 +74,6 @@ async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) { resolve(file); return; }
|
||||
|
||||
// PNG/GIF have transparency — paint white behind them first so toBlob
|
||||
// doesn't hand back a canvas with undefined/black fill in some browsers.
|
||||
// (Skip this for formats that don't carry alpha, like plain JPEG.)
|
||||
const mimeType = file.type || "image/jpeg";
|
||||
if (mimeType !== "image/jpeg") {
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
@@ -92,14 +82,10 @@ async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// quality param is meaningless (and occasionally mishandled) for
|
||||
// lossless formats — only pass it for jpeg/webp.
|
||||
const isLossy = mimeType === "image/jpeg" || mimeType === "image/webp";
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
// Guard: empty/undersized blob means the encode failed — fall
|
||||
// back to the original file rather than uploading garbage.
|
||||
if (!blob || blob.size < 100) {
|
||||
resolve(file);
|
||||
return;
|
||||
@@ -117,7 +103,7 @@ async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<
|
||||
}
|
||||
|
||||
async function compressPayloadImages<
|
||||
T extends { photo?: File; nationalPhoto?: File; driverCardPhoto?: File },
|
||||
T extends { photo?: File; nationalPhoto?: File; driverCardPhoto?: File }
|
||||
>(payload: T): Promise<T> {
|
||||
const result = { ...payload };
|
||||
if (result.photo) result.photo = await compressImage(result.photo);
|
||||
@@ -129,51 +115,48 @@ async function compressPayloadImages<
|
||||
// ── Service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const driverService = {
|
||||
/** GET /driver — paginated + searchable list */
|
||||
getAll: async (page = 1, search = "", token: string | null): Promise<DriverListResponse> => {
|
||||
const res = await get<DriverListResponse>(
|
||||
/** GET /driver — returns a clean, unwrapped, fully-typed result. */
|
||||
getAll: async (page = 1, search = "", token: string | null): Promise<DriverListResult> => {
|
||||
const res: DriverListResponse = await get<DriverListResponse>(
|
||||
`driver${buildQuery({ page, limit: 12, search: search || undefined })}`,
|
||||
token,
|
||||
);
|
||||
const body = (res as unknown as { data: { data: Driver[]; pagination: unknown; meta?: unknown } }).data;
|
||||
body.data = body.data.map(mapDriverImages);
|
||||
return res;
|
||||
const body = res.data;
|
||||
return {
|
||||
items: body.data.map(mapDriverImages),
|
||||
total: body.meta?.total ?? body.pagination?.total ?? 0,
|
||||
pages: body.meta?.pages ?? body.pagination?.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
/** GET /driver/archived */
|
||||
getArchived: async (token: string | null): Promise<DriverListResponse> => {
|
||||
const res = await get<DriverListResponse>("driver/archived", token);
|
||||
const body = (res as unknown as { data: { data: Driver[] } }).data;
|
||||
body.data = body.data.map(mapDriverImages);
|
||||
return res;
|
||||
getArchived: async (token: string | null): Promise<Driver[]> => {
|
||||
const res: DriverListResponse = await get<DriverListResponse>("driver/archived", token);
|
||||
return res.data.data.map(mapDriverImages);
|
||||
},
|
||||
|
||||
/** GET /driver/me */
|
||||
getMe: async (token: string | null): Promise<DriverDetailResponse> => {
|
||||
const res = await get<DriverDetailResponse>("driver/me", token);
|
||||
const body = res as unknown as { data: Driver };
|
||||
body.data = mapDriverImages(body.data);
|
||||
return res;
|
||||
getMe: async (token: string | null): Promise<Driver> => {
|
||||
const res: DriverDetailResponse = await get<DriverDetailResponse>("driver/me", token);
|
||||
return mapDriverImages(res.data);
|
||||
},
|
||||
|
||||
/** GET /driver/:id */
|
||||
getById: async (id: string, token: string | null): Promise<DriverDetailResponse> => {
|
||||
const res = await get<DriverDetailResponse>(`driver/${id}`, token);
|
||||
const body = res as unknown as { data: Driver };
|
||||
body.data = mapDriverImages(body.data);
|
||||
return res;
|
||||
getById: async (id: string, token: string | null): Promise<Driver> => {
|
||||
const res: DriverDetailResponse = await get<DriverDetailResponse>(`driver/${id}`, token);
|
||||
return mapDriverImages(res.data);
|
||||
},
|
||||
|
||||
/** POST /driver (JSON — no files) */
|
||||
create: (payload: CreateDriverPayload, token: string | null) =>
|
||||
post<{ data: Driver }>("driver", payload, token),
|
||||
create: async (payload: CreateDriverPayload, token: string | null): Promise<Driver> => {
|
||||
const res = await post<{ data: Driver }>("driver", payload, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** PATCH /driver/:id (JSON — no files) */
|
||||
update: async (id: string, payload: UpdateDriverPayload, token: string | null): Promise<{ data: Driver }> => {
|
||||
update: async (id: string, payload: UpdateDriverPayload, token: string | null): Promise<Driver> => {
|
||||
const res = await patch<{ data: Driver }>(`driver/${id}`, payload, token);
|
||||
const body = res as unknown as { data: Driver };
|
||||
body.data = mapDriverImages(body.data);
|
||||
return res;
|
||||
return mapDriverImages(res.data);
|
||||
},
|
||||
|
||||
/** DELETE /driver/:id */
|
||||
@@ -181,18 +164,16 @@ export const driverService = {
|
||||
del<void>(`driver/${id}`, token),
|
||||
|
||||
/** GET /drivers/:id/reports/daily?date=YYYY-MM-DD */
|
||||
getDailyReport: (id: string, date: string, token: string | null) =>
|
||||
get<DriverReportResponse>(
|
||||
getDailyReport: async (id: string, date: string, token: string | null): Promise<DriverReportResult> => {
|
||||
const res: DriverReportResponse = await get<DriverReportResponse>(
|
||||
`drivers/${id}/reports/daily?date=${encodeURIComponent(date)}`,
|
||||
token,
|
||||
),
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// ── Multipart helpers (with auto compression) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /driver (multipart/form-data)
|
||||
* Compresses images before upload to stay under backend LIMIT_FILE_SIZE.
|
||||
*/
|
||||
createWithImages: async (
|
||||
payload: CreateDriverPayload & {
|
||||
photo?: File;
|
||||
@@ -200,7 +181,7 @@ export const driverService = {
|
||||
driverCardPhoto?: File;
|
||||
},
|
||||
token: string | null,
|
||||
): Promise<{ data: Driver }> => {
|
||||
): Promise<Driver> => {
|
||||
const compressed = await compressPayloadImages(payload);
|
||||
|
||||
const form = new FormData();
|
||||
@@ -225,14 +206,9 @@ export const driverService = {
|
||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as { data: Driver };
|
||||
data.data = mapDriverImages(data.data);
|
||||
return data;
|
||||
return mapDriverImages(data.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /driver/:id (multipart/form-data)
|
||||
* Compresses images before upload to stay under backend LIMIT_FILE_SIZE.
|
||||
*/
|
||||
updateWithImages: async (
|
||||
id: string,
|
||||
payload: UpdateDriverPayload & {
|
||||
@@ -241,7 +217,7 @@ export const driverService = {
|
||||
driverCardPhoto?: File;
|
||||
},
|
||||
token: string | null,
|
||||
): Promise<{ data: Driver }> => {
|
||||
): Promise<Driver> => {
|
||||
const compressed = await compressPayloadImages(payload);
|
||||
|
||||
const form = new FormData();
|
||||
@@ -256,7 +232,6 @@ export const driverService = {
|
||||
|
||||
const res = await fetch(`/api/proxy/driver/${id}`, {
|
||||
method: "PATCH",
|
||||
// Do NOT set Content-Type — browser sets it with the correct boundary
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
cache: "no-store",
|
||||
@@ -267,7 +242,15 @@ export const driverService = {
|
||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as { data: Driver };
|
||||
data.data = mapDriverImages(data.data);
|
||||
return data;
|
||||
return mapDriverImages(data.data);
|
||||
},
|
||||
|
||||
/** Dropdown helper — replaces raw get<T>() calls in TripFormModal. */
|
||||
getActiveOptions: async (token: string | null): Promise<DriverOption[]> => {
|
||||
const res = await get<{ data: { data: DriverOption[] } }>(
|
||||
"driver?limit=100&status=Active",
|
||||
token,
|
||||
);
|
||||
return res.data.data;
|
||||
},
|
||||
};
|
||||
@@ -6,32 +6,56 @@ import type {
|
||||
UpdateOrderPayload,
|
||||
UpdateOrderStatusPayload,
|
||||
TransferOrderPayload,
|
||||
OrdersResponse,
|
||||
OrderListResult,
|
||||
} from "@/src/types/order";
|
||||
|
||||
/**
|
||||
* One-time defensive unwrap for order endpoints. Documented here because
|
||||
* the backend for this resource isn't 100% consistent about wrapping
|
||||
* responses in `{ data }` — some handlers return the entity directly.
|
||||
*/
|
||||
function unwrapOrder(res: Order | { data: Order }): Order {
|
||||
const maybeWrapped = res as { data?: Order };
|
||||
return maybeWrapped?.data ? maybeWrapped.data : (res as Order);
|
||||
}
|
||||
|
||||
interface OrdersListEnvelope {
|
||||
data: {
|
||||
data: Order[];
|
||||
meta?: { total: number; page: number; limit: number; totalPages: number };
|
||||
};
|
||||
}
|
||||
|
||||
export const orderService = {
|
||||
/** Get all orders (paginated) */
|
||||
getAll: (params?: Record<string, string | number>) => {
|
||||
/** Get all orders (paginated) — returns a clean, unwrapped result. */
|
||||
getAll: async (params?: Record<string, string | number>): Promise<OrderListResult> => {
|
||||
const qs = params
|
||||
? "?" + new URLSearchParams(params as Record<string, string>).toString()
|
||||
: "";
|
||||
return get<OrdersResponse>(`orders${qs}`);
|
||||
const res = await get<OrdersListEnvelope>(`orders${qs}`);
|
||||
const payload = res.data;
|
||||
return {
|
||||
items: payload.data ?? [],
|
||||
total: payload.meta?.total ?? 0,
|
||||
pages: payload.meta?.totalPages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
/** Get order by ID */
|
||||
getById: (id: string) => get<Order>(`orders/${id}`),
|
||||
getById: async (id: string): Promise<Order> =>
|
||||
unwrapOrder(await get<Order | { data: Order }>(`orders/${id}`)),
|
||||
|
||||
/** Create new order */
|
||||
create: (payload: CreateOrderPayload) =>
|
||||
post<Order>("orders", payload),
|
||||
create: async (payload: CreateOrderPayload): Promise<Order> =>
|
||||
unwrapOrder(await post<Order | { data: Order }>("orders", payload)),
|
||||
|
||||
/** Update order metadata */
|
||||
update: (id: string, payload: UpdateOrderPayload) =>
|
||||
patch<Order>(`orders/${id}`, payload),
|
||||
update: async (id: string, payload: UpdateOrderPayload): Promise<Order> =>
|
||||
unwrapOrder(await patch<Order | { data: Order }>(`orders/${id}`, payload)),
|
||||
|
||||
/** Update order status with optional reason */
|
||||
updateStatus: (id: string, payload: UpdateOrderStatusPayload) =>
|
||||
patch<Order>(`orders/${id}/status`, payload),
|
||||
updateStatus: async (id: string, payload: UpdateOrderStatusPayload): Promise<Order> =>
|
||||
unwrapOrder(await patch<Order | { data: Order }>(`orders/${id}/status`, payload)),
|
||||
|
||||
/** Transfer order to a different trip */
|
||||
transfer: (id: string, payload: TransferOrderPayload) =>
|
||||
@@ -41,5 +65,8 @@ export const orderService = {
|
||||
delete: (id: string) => del<void>(`orders/${id}`),
|
||||
|
||||
/** Get archived orders */
|
||||
getArchived: () => get<OrdersResponse>("orders/archived"),
|
||||
getArchived: async (): Promise<Order[]> => {
|
||||
const res = await get<{ data: { data: Order[] } }>("orders/archived");
|
||||
return res.data.data ?? [];
|
||||
},
|
||||
};
|
||||
@@ -1,53 +1,58 @@
|
||||
import {
|
||||
ApiPaginatedResponse,
|
||||
AssignPermissionResponse,
|
||||
BulkAssignPermissionsResponse,
|
||||
PermissionsResponse,
|
||||
Role,
|
||||
RoleFormData,
|
||||
RoleResponse,
|
||||
ApiPaginatedResponse, AssignPermissionResponse, BulkAssignPermissionsResponse,
|
||||
PermissionsResponse, Permission, Role, RoleFormData, RoleResponse, RoleListResult,
|
||||
} from "../types/role";
|
||||
import { get, post, put, del, patch } from "./api";
|
||||
|
||||
/** Build query string for paginated role listing */
|
||||
function buildRolesQuery(page: number, search: string): string {
|
||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
}
|
||||
|
||||
export const roleService = {
|
||||
/** Fetch paginated roles list */
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ApiPaginatedResponse<Role>>(`role${buildRolesQuery(page, search)}`, token),
|
||||
getAll: async (page: number, search: string, token: string | null): Promise<RoleListResult> => {
|
||||
const res: ApiPaginatedResponse<Role> = await get<ApiPaginatedResponse<Role>>(
|
||||
`role${buildRolesQuery(page, search)}`,
|
||||
token,
|
||||
);
|
||||
return {
|
||||
items: res.data.data,
|
||||
total: res.data.meta?.total ?? res.data.pagination?.total ?? 0,
|
||||
pages: res.data.meta?.pages ?? res.data.pagination?.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
/** Fetch a single role by ID (includes permissions) */
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<{ data: Role }>(`role/${id}`, token),
|
||||
getById: async (id: string, token: string | null): Promise<Role> => {
|
||||
const res = await get<{ data: Role }>(`role/${id}`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** Create a new role */
|
||||
create: (data: RoleFormData, token: string | null) =>
|
||||
post<RoleResponse>("role", { name: data.name, description: data.description }, token),
|
||||
create: async (data: RoleFormData, token: string | null): Promise<Role> => {
|
||||
const res: RoleResponse = await post<RoleResponse>(
|
||||
"role", { name: data.name, description: data.description }, token,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** Update role name/description */
|
||||
update: (id: string, data: Partial<RoleFormData>, token: string | null) =>
|
||||
put<RoleResponse>(`role/${id}`, { name: data.name, description: data.description }, token),
|
||||
update: async (id: string, data: Partial<RoleFormData>, token: string | null): Promise<Role> => {
|
||||
const res: RoleResponse = await put<RoleResponse>(
|
||||
`role/${id}`, { name: data.name, description: data.description }, token,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** Soft-delete a role */
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`role/${id}`, token),
|
||||
delete: (id: string, token: string | null) => del<void>(`role/${id}`, token),
|
||||
|
||||
/** Fetch all available permissions for checkbox list */
|
||||
getPermissions: (token: string | null) =>
|
||||
get<PermissionsResponse>("premission?limit=200", token),
|
||||
getPermissions: async (token: string | null): Promise<Permission[]> => {
|
||||
const res: PermissionsResponse = await get<PermissionsResponse>("premission?limit=200", token);
|
||||
return res.data?.premissions?.data ?? [];
|
||||
},
|
||||
|
||||
/** POST /role/{id}/permissions — assign a single permission to a role */
|
||||
assignPermission: (roleId: string, permissionId: string, token: string | null) =>
|
||||
post<AssignPermissionResponse>(`role-permissions/${roleId}/permissions`, { permissionId }, token),
|
||||
|
||||
/** PATCH /role/{id}/permissions/bulk — replace all permissions for a role */
|
||||
bulkAssignPermissions: (roleId: string, permissionIds: string[], token: string | null) =>
|
||||
patch<BulkAssignPermissionsResponse>(`role-permissions/${roleId}/permissions`, { permissionIds }, token),
|
||||
|
||||
/** DELETE /role/{id}/permissions/{permissionId} — remove a single permission */
|
||||
removePermission: (roleId: string, permissionId: string, token: string | null) =>
|
||||
del<void>(`role-permissions/${roleId}/permissions/${permissionId}`, token),
|
||||
};
|
||||
@@ -1,109 +1,68 @@
|
||||
import { get, post, patch, del } from "./api";
|
||||
import type {
|
||||
Trip,
|
||||
TripListResponse,
|
||||
TripDetailResponse,
|
||||
TripReportResponse,
|
||||
CreateTripPayload,
|
||||
UpdateTripPayload,
|
||||
TripListParams,
|
||||
Trip, TripListResponse, TripDetailResponse, TripReportResponse,
|
||||
TripListResult, TripReportResult,
|
||||
CreateTripPayload, UpdateTripPayload, TripListParams,
|
||||
ArchivedTripListResponse, ArchivedTripResponse,
|
||||
} from "@/src/types/trip";
|
||||
|
||||
/** Build a query string for list endpoints */
|
||||
function buildQuery(
|
||||
params: Record<string, string | number | undefined>,
|
||||
): string {
|
||||
const entries = Object.entries(params).filter(
|
||||
([, v]) => v !== undefined && v !== "",
|
||||
);
|
||||
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
||||
if (!entries.length) return "";
|
||||
return (
|
||||
"?" +
|
||||
entries
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
||||
.join("&")
|
||||
);
|
||||
return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
|
||||
}
|
||||
|
||||
export const tripService = {
|
||||
/**
|
||||
* GET /trip
|
||||
* Fetch paginated + filterable + searchable trip list.
|
||||
* Requires: "read-trip" permission
|
||||
*/
|
||||
getAll: (params: TripListParams = {}, token: string | null) =>
|
||||
get<TripListResponse>(`trip${buildQuery({ ...params })}`, token),
|
||||
getAll: async (params: TripListParams = {}, token: string | null): Promise<TripListResult> => {
|
||||
const res: TripListResponse = await get<TripListResponse>(`trip${buildQuery({ ...params })}`, token);
|
||||
return {
|
||||
items: res.data.data,
|
||||
total: res.data.pagination?.total ?? 0,
|
||||
pages: res.data.pagination?.totalPages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /trip/archived
|
||||
* Fetch soft-deleted (archived) trips. Same filters as getAll.
|
||||
* Requires: "read-deleted-trip" permission
|
||||
*/
|
||||
getArchived: (params: TripListParams = {}, token: string | null) =>
|
||||
get<TripListResponse>(`trip/archived${buildQuery({ ...params })}`, token),
|
||||
getArchived: async (params: TripListParams = {}, token: string | null): Promise<TripListResult> => {
|
||||
const res: ArchivedTripListResponse = await get<ArchivedTripListResponse>(
|
||||
`trip/archived${buildQuery({ ...params })}`,
|
||||
token,
|
||||
);
|
||||
return {
|
||||
items: res.data.data,
|
||||
total: res.data.meta.total,
|
||||
pages: res.data.meta.totalPages,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /trip/archived/:id
|
||||
* Fetch a single archived trip with full details.
|
||||
* Requires: "read-deleted-trip" permission
|
||||
*/
|
||||
getArchivedById: (id: string, token: string | null) =>
|
||||
get<TripDetailResponse>(`trip/archived/${id}`, token),
|
||||
getArchivedById: async (id: string, token: string | null): Promise<Trip> => {
|
||||
const res: ArchivedTripResponse = await get<ArchivedTripResponse>(`trip/archived/${id}`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /trip/:id
|
||||
* Fetch a single trip with full driver/car details.
|
||||
* Requires: "read-trip" permission
|
||||
*/
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<TripDetailResponse>(`trip/${id}`, token),
|
||||
getById: async (id: string, token: string | null): Promise<Trip> => {
|
||||
const res: TripDetailResponse = await get<TripDetailResponse>(`trip/${id}`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /trip
|
||||
* Create a new trip.
|
||||
* Side effect: driver & car flip to status "InTrip" automatically.
|
||||
* Requires: "create-trip" permission
|
||||
*/
|
||||
create: (payload: CreateTripPayload, token: string | null) =>
|
||||
post<{ data: Trip }>("trip", payload, token),
|
||||
create: async (payload: CreateTripPayload, token: string | null): Promise<Trip> => {
|
||||
const res = await post<{ data: Trip }>("trip", payload, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /trip/:id
|
||||
* Update trip data / status / reassign driver or car.
|
||||
* Side effects:
|
||||
* - changing driverId: old driver -> Active, new driver -> InTrip
|
||||
* - changing carId: same logic for cars
|
||||
* - status Completed/Cancelled: driver & car both -> Active
|
||||
* Requires: "update-trip" permission
|
||||
*/
|
||||
update: (id: string, payload: UpdateTripPayload, token: string | null) =>
|
||||
patch<{ data: Trip }>(`trip/${id}`, payload, token),
|
||||
update: async (id: string, payload: UpdateTripPayload, token: string | null): Promise<Trip> => {
|
||||
const res = await patch<{ data: Trip }>(`trip/${id}`, payload, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /trip/:id
|
||||
* Soft-delete a trip (isDeleted=true, deletedAt=now()). 204 No Content.
|
||||
* Requires: "delete-trip" permission
|
||||
*/
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`trip/${id}`, token),
|
||||
delete: (id: string, token: string | null) => del<void>(`trip/${id}`, token),
|
||||
|
||||
// ── Reports ───────────────────────────────────────────────────────────────
|
||||
getReport: async (id: string, token: string | null): Promise<TripReportResult> => {
|
||||
const res: TripReportResponse = await get<TripReportResponse>(`trip/${id}/reports`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /trip/:id/reports
|
||||
* Generate the Trip Manifest report (HTML, saved server-side).
|
||||
* Returns { reportUrl }
|
||||
* Requires: "generate-trip-report" permission
|
||||
*/
|
||||
getReport: (id: string, token: string | null) =>
|
||||
get<TripReportResponse>(`trip/${id}/reports`, token),
|
||||
|
||||
/**
|
||||
* GET /trip/:id/reports/client/:clientId
|
||||
* Generate a client-filtered version of the trip report.
|
||||
* Returns { reportUrl }
|
||||
* Requires: "generate-trip-report" permission
|
||||
*/
|
||||
getClientReport: (id: string, clientId: string, token: string | null) =>
|
||||
get<TripReportResponse>(`trip/${id}/reports/client/${clientId}`, token),
|
||||
getClientReport: async (id: string, clientId: string, token: string | null): Promise<TripReportResult> => {
|
||||
const res: TripReportResponse = await get<TripReportResponse>(`trip/${id}/reports/client/${clientId}`, token);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import { get, post, put, del } from "./api";
|
||||
import type { ApiListResponse, User, UserFormData, UserResponse, UserMe } from "@/src/types/user";
|
||||
import type {
|
||||
ApiListResponse, User, UserFormData, UserResponse, UserMe, UserListResult, UserDetail,
|
||||
} from "@/src/types/user";
|
||||
import type { Role } from "@/src/types/role";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
|
||||
// الشكل الحقيقي لاستجابة /users/me:
|
||||
// { success, message, responseAt, data: <UserMe> }
|
||||
// أي "data" هنا هي بيانات اليوزر مباشرة، مفيش data.data متداخلة.
|
||||
export interface MeApiResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -13,83 +12,74 @@ export interface MeApiResponse {
|
||||
data: UserMe;
|
||||
}
|
||||
|
||||
|
||||
/**Building a query string to fetch users with search and pagination*/
|
||||
function buildUsersQuery(page: number, search: string): string {
|
||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
}
|
||||
|
||||
export const userService = {
|
||||
/** Fetching the user list with the ability to search and browse through pages*/
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
|
||||
getAll: async (page: number, search: string, token: string | null): Promise<UserListResult> => {
|
||||
const res: ApiListResponse<User> = await get<ApiListResponse<User>>(
|
||||
`users${buildUsersQuery(page, search)}`, token,
|
||||
);
|
||||
return {
|
||||
items: res.data.data,
|
||||
total: res.data.meta?.total ?? res.data.pagination?.total ?? 0,
|
||||
pages: res.data.meta?.pages ?? res.data.pagination?.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
/** create new account*/
|
||||
create: (
|
||||
create: async (
|
||||
data: Omit<UserFormData, "password"> & { password: string },
|
||||
token: string | null,
|
||||
) => post<UserResponse>("users", buildPayload(data, true), token),
|
||||
): Promise<User> => {
|
||||
const res: UserResponse = await post<UserResponse>("users", buildPayload(data, true), token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**Edit existing user info*/
|
||||
update: (id: string, data: UserFormData, token: string | null) =>
|
||||
put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
|
||||
update: async (id: string, data: UserFormData, token: string | null): Promise<User> => {
|
||||
const res: UserResponse = await put<UserResponse>(`users/${id}`, buildPayload(data, false), token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/** delete user*/
|
||||
delete: (id: string, token: string | null) => del<void>(`users/${id}`, token),
|
||||
//get role
|
||||
getRoles: (token: string | null) =>
|
||||
get<{ data: { data: Role[] } }>("role?limit=100", token),
|
||||
//get Branches for user form
|
||||
getBranches: (token: string | null) =>
|
||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token),
|
||||
//get user by id
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<{
|
||||
data: User & {
|
||||
photo: string | null;
|
||||
refreshToken: string | null;
|
||||
isDeleted: boolean;
|
||||
deletedAt: string | null;
|
||||
updatedAt: string;
|
||||
passwordChangedAt: string | null;
|
||||
role: { name: string; description: string };
|
||||
branch: { name: string };
|
||||
};
|
||||
}>(`users/${id}`, token),
|
||||
getMe: (token: string | null) =>
|
||||
get<MeApiResponse>("users/me", token),
|
||||
|
||||
getRoles: async (token: string | null): Promise<Role[]> => {
|
||||
const res = await get<{ data: { data: Role[] } }>("role?limit=100", token);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
getBranches: async (token: string | null): Promise<Branch[]> => {
|
||||
const res = await get<{ data: { data: Branch[] } }>("branches?limit=100", token);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
getById: async (id: string, token: string | null): Promise<UserDetail> => {
|
||||
const res = await get<{ data: UserDetail }>(`users/${id}`, token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getMe: (token: string | null) => get<MeApiResponse>("users/me", token),
|
||||
};
|
||||
|
||||
/**
|
||||
* بتفكك استجابة /users/me لأي شكل ييجي بيه من السيرفر:
|
||||
* - سواء الـ get() بيرجع الـ body مباشرة: { success, data: <user> }
|
||||
* - أو بيرجع wrapper شكله axios-like: { data: { success, data: <user> } }
|
||||
* فبناخد أول data موجودة (لو فيها success/message نبقى في المكان الصح)،
|
||||
* وبعدين ناخد الـ data اللي جواها (بيانات اليوزر الفعلية).
|
||||
* ملحوظة: مفيش تفكيك مزدوج بره الدالة دي، وأي حد بينادي عليها
|
||||
* لازم يبعتلها الـ response زي ما هو من غير ما يعمل res.data قبلها.
|
||||
* One-time defensive unwrap for /users/me, which has been observed to
|
||||
* return either { success, data } directly or { data: { success, data } }.
|
||||
* Documented and isolated here — nothing outside the service ever sees
|
||||
* the ambiguity.
|
||||
*/
|
||||
export function extractMeUser(res: MeApiResponse | { data: MeApiResponse }): UserMe | null {
|
||||
const body: MeApiResponse = (res as { data: MeApiResponse })?.data?.data
|
||||
? (res as { data: MeApiResponse }).data
|
||||
: (res as MeApiResponse);
|
||||
|
||||
const d = body?.data;
|
||||
if (!d) return null;
|
||||
if (Array.isArray(d)) return (d[0] as UserMe) ?? null;
|
||||
return d as UserMe;
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Converting the form data into a payload suitable for the API */
|
||||
function buildPayload(
|
||||
data: UserFormData,
|
||||
isNew: boolean,
|
||||
): Record<string, string> {
|
||||
function buildPayload(data: UserFormData, isNew: boolean): Record<string, string> {
|
||||
const payload: Record<string, string> = {
|
||||
name: data.name.trim(),
|
||||
phone: data.phone.trim(),
|
||||
roleId: data.roleId,
|
||||
branchId: data.branchId,
|
||||
name: data.name.trim(), phone: data.phone.trim(), roleId: data.roleId, branchId: data.branchId,
|
||||
};
|
||||
if (data.email) payload.email = data.email.trim();
|
||||
if (isNew && data.password) payload.password = data.password;
|
||||
|
||||
@@ -120,4 +120,15 @@ export interface ArchivedBranchListResponse {
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BranchListResult {
|
||||
items: Branch[];
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface BranchOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
@@ -222,4 +222,18 @@ export interface CarFormErrors {
|
||||
gpsDeviceId?: string;
|
||||
capacity?: string;
|
||||
weight?: string;
|
||||
|
||||
}
|
||||
export interface CarListResult {
|
||||
items: Car[];
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface CarOption {
|
||||
id: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
plateNumber: string;
|
||||
status?: string;
|
||||
}
|
||||
@@ -153,4 +153,9 @@ export interface ArchivedClientOrdersResponse {
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface ClientListResult {
|
||||
items: Client[];
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export interface Driver {
|
||||
driverCardPhotoUrl?: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface DriverStatusHistoryEntry {
|
||||
id: string;
|
||||
status: DriverStatus;
|
||||
@@ -183,4 +184,24 @@ export interface ArchivedDriverResponse {
|
||||
// GET /driver/archived/driverStatus/{id}
|
||||
export interface ArchivedDriverStatusHistoryResponse {
|
||||
data: DriverStatusHistoryEntry[];
|
||||
}
|
||||
// ── Clean, unwrapped shapes returned by driverService ──
|
||||
|
||||
export interface DriverListResult {
|
||||
items: Driver[];
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface DriverReportResult {
|
||||
reportUrl: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
/** Minimal shape used by dropdowns (TripFormModal etc.) */
|
||||
export interface DriverOption {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: DriverStatus;
|
||||
}
|
||||
@@ -135,5 +135,14 @@ export interface ArchivedOrderListResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
responseAt: string;
|
||||
data: ArchivedOrder[];
|
||||
data: {
|
||||
data: ArchivedOrder[];
|
||||
meta?: { total: number; page: number; limit: number; totalPages: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrderListResult {
|
||||
items: Order[];
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export interface RoleResponse {
|
||||
|
||||
export interface PermissionsResponse {
|
||||
data: {
|
||||
premissions: Permission[];
|
||||
premissions: { data: Permission[] };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,9 +94,17 @@ 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.
|
||||
// GET /role/archived — like the other archive endpoints, this returns
|
||||
// { data: { data: ArchivedRole[], meta: {...} } }.
|
||||
// Search & pagination are still handled client-side in the hook.
|
||||
export interface ArchivedRoleListResponse {
|
||||
data: ArchivedRole[];
|
||||
data: {
|
||||
data: ArchivedRole[];
|
||||
meta?: { total: number; page: number; limit: number; totalPages: number };
|
||||
};
|
||||
}
|
||||
export interface RoleListResult {
|
||||
items: Role[];
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
@@ -172,4 +172,13 @@ export interface ArchivedTripListResponse {
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface TripListResult {
|
||||
items: Trip[];
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface TripReportResult {
|
||||
reportUrl: string;
|
||||
}
|
||||
@@ -137,4 +137,9 @@ export interface UserMe extends User {
|
||||
updatedAt: string;
|
||||
role?: UserRoleDetail | null;
|
||||
branch?: { id?: string; name: string } | null;
|
||||
}
|
||||
export interface UserListResult {
|
||||
items: User[];
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
Reference in New Issue
Block a user