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:
m7amedez5511
2026-07-19 14:57:48 +03:00
parent bbde177f4a
commit 538111d726
82 changed files with 1441 additions and 2261 deletions

View File

@@ -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");

View File

@@ -363,6 +363,7 @@ function SidebarContent({
pathname.startsWith(item.href + "/"));
return (
<Link
suppressHydrationWarning
key={item.href}
href={item.href}
title={compact ? item.label : undefined}

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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 && (

View File

@@ -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 && (

View File

@@ -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})؟ لا يمكن التراجع عن هذا الإجراء.`}
/>
</>
);
}

View File

@@ -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 && (

View File

@@ -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 && (

View File

@@ -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 && (

View File

@@ -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})؟ لا يمكن التراجع عن هذا الإجراء.`}
/>
</>
);
}

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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 && (

View File

@@ -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}

View File

@@ -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) =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) =>

View File

@@ -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={{

View File

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

View File

@@ -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={{

View File

@@ -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",

View File

@@ -0,0 +1,2 @@
export {OrderFormModal} from './OrderFormModal';
export {OrderDetailPanel} from './OrderDetailBanel';

View File

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

View File

@@ -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&currentStatus=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 ?? "");

View File

@@ -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={() => {

View File

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

View File

@@ -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) =>

View File

@@ -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) =>

View File

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

View File

@@ -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();

View File

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

View File

@@ -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",

View File

@@ -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) =>

View File

@@ -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]);

View File

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

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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(

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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 ?? [],
};

View File

@@ -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 : [];
},
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&currentStatus=Active", token);
return res.data.data;
},
};

View File

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

View File

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

View File

@@ -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 }));
},
};
@@ -88,3 +104,5 @@ function buildPayload(
location: data.location,
};
}
export default { normalizeAddress };

View File

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

View File

@@ -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 ?? [];
},
};

View File

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

View File

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

View File

@@ -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;

View File

@@ -121,3 +121,14 @@ export interface ArchivedBranchListResponse {
};
};
}
export interface BranchListResult {
items: Branch[];
total: number;
pages: number;
}
export interface BranchOption {
id: string;
name: string;
}

View File

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

View File

@@ -154,3 +154,8 @@ export interface ArchivedClientOrdersResponse {
};
};
}
export interface ClientListResult {
items: Client[];
total: number;
pages: number;
}

View File

@@ -39,6 +39,7 @@ export interface Driver {
driverCardPhotoUrl?: string | null;
}
export interface DriverStatusHistoryEntry {
id: string;
status: DriverStatus;
@@ -184,3 +185,23 @@ export interface ArchivedDriverResponse {
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;
}

View File

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

View File

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

View File

@@ -173,3 +173,12 @@ export interface ArchivedTripListResponse {
};
};
}
export interface TripListResult {
items: Trip[];
total: number;
pages: number;
}
export interface TripReportResult {
reportUrl: string;
}

View File

@@ -138,3 +138,8 @@ export interface UserMe extends User {
role?: UserRoleDetail | null;
branch?: { id?: string; name: string } | null;
}
export interface UserListResult {
items: User[];
total: number;
pages: number;
}