fix: resolve permission fallback bug, remove debug code, and cleanup duplication
Logic Errors ------------ - Sidebar: fix permissions fallback so a user with no permissions gets zero access instead of falling back to the full nav list (user?.permissions ?? [] instead of ?? navSections.flatMap(...)) - carMaintanance.service / UseCarsMaintanance: add pagination to the maintenance records fetch instead of loading the entire list at once - api.ts: redact sensitive fields (password, token) before logging the request body in console.debug, to avoid leaking credentials/PII Code Flow Problems ------------------- - OrderFormModal: remove leftover console.log/debug statements and the redundant onClick handler on the submit button - auth: remove the unused src/service/auth.service.ts (axios) and keep only src/services/auth.service.ts (fetch); fix useAuth.ts import path from
This commit is contained in:
@@ -1,12 +1,21 @@
|
|||||||
|
// app/components/layout/ConditionalNavbar.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { getStoredUser } from "@/src/lib/auth";
|
import { useStoredUser } from "@/src/hooks/useStoredUser";
|
||||||
import Navbar from "./Navbar";
|
import Navbar from "./Navbar";
|
||||||
|
|
||||||
export default function ConditionalNavbar() {
|
export default function ConditionalNavbar() {
|
||||||
const pathname = usePathname();
|
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");
|
const isDashboardRoute = pathname?.startsWith("/dashboard");
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ function SidebarContent({
|
|||||||
pathname.startsWith(item.href + "/"));
|
pathname.startsWith(item.href + "/"));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
suppressHydrationWarning
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
title={compact ? item.label : undefined}
|
title={compact ? item.label : undefined}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
|
// app/components/layout/Topbar.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
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";
|
import { useSidebarDrawer, BrandIconButton } from "./Sidebar";
|
||||||
|
|
||||||
export function Topbar() {
|
export function Topbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setOpen } = useSidebarDrawer();
|
const { setOpen } = useSidebarDrawer();
|
||||||
|
|
||||||
|
// كان بيتقرأ بشكل sync عن طريق getStoredUser() جوه الرندر — استبدلناه بالـ hook
|
||||||
|
// عشان يبقى SSR-safe ومايعملش hydration mismatch، وكمان يعمل subscribe
|
||||||
|
// لأي تغيير في الـ storage (تسجيل دخول/خروج من تاب تاني).
|
||||||
|
const { user, loading } = useStoredUser();
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await clearAuth();
|
await clearAuth();
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = getStoredUser();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
suppressHydrationWarning
|
|
||||||
style={{
|
style={{
|
||||||
height: "var(--topbar-height)",
|
height: "var(--topbar-height)",
|
||||||
background: "#FFFFFF",
|
background: "#FFFFFF",
|
||||||
@@ -52,18 +57,33 @@ export function Topbar() {
|
|||||||
حسابي
|
حسابي
|
||||||
</button>
|
</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
|
<div
|
||||||
suppressHydrationWarning
|
role="status"
|
||||||
|
aria-label={loading ? "جارِ التحميل" : undefined}
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "var(--color-text-secondary)",
|
color: "var(--color-text-secondary)",
|
||||||
background: "var(--color-surface-muted)",
|
background: "var(--color-surface-muted)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
borderRadius: "var(--radius-full)",
|
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Toast, ArchiveButton } from "@/src/Components/UI";
|
import { Alert, Toast, ArchiveButton ,ConfirmDialog } from "@/src/Components/UI";
|
||||||
import { BranchTable } from "@/src/Components/Branch/BranchTable";
|
import { BranchDetailModal, BranchTable ,BranchFormModal } from "@/src/Components/Branch";
|
||||||
import { BranchFormModal } from "@/src/Components/Branch/BranchFormModal";
|
|
||||||
import { BranchDetailModal } from "@/src/Components/Branch/BranchDetailModal";
|
|
||||||
import { DeleteConfirmModal } from "@/src/Components/Branch/DeleteConfirmModal";
|
|
||||||
import { useBranches } from "@/src/hooks/useBranch";
|
import { useBranches } from "@/src/hooks/useBranch";
|
||||||
import type { Branch, BranchFormData } from "@/src/types/branch";
|
import type { Branch, BranchFormData } from "@/src/types/branch";
|
||||||
import { ArchivedBranchesModal } from "@/src/Components/Branch/archive/ArchivedBranchesModal";
|
import { ArchivedBranchesModal } from "@/src/Components/Branch/archive/ArchivedBranchesModal";
|
||||||
@@ -74,16 +71,14 @@ export default function BranchesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<DeleteConfirmModal
|
open={!!deleteTarget}
|
||||||
branch={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
onCancel={() => {
|
onConfirm={handleDeleteConfirm}
|
||||||
if (!deleting) setDeleteTarget(null);
|
title="حذف الفرع"
|
||||||
}}
|
description={`هل أنت متأكد من حذف الفرع ${deleteTarget?.name ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
onConfirm={handleDeleteConfirm}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Archive browser modal */}
|
{/* Archive browser modal */}
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { 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 { ArchivedCarsMaintenanceModal } from "@/src/Components/Car_Maintanance/archive/ArchivedCarsMaintananceModal";
|
||||||
import {
|
import {
|
||||||
useCarMaintenanceList,
|
useCarMaintenanceList,
|
||||||
@@ -135,14 +134,14 @@ export default function CarMaintenancePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<CarMaintenanceDeleteModal
|
open={!!deleteTarget}
|
||||||
record={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onConfirm={() => deleteTarget && handleDeleteConfirm(deleteTarget)}
|
||||||
onConfirm={() => handleDeleteConfirm(deleteTarget)}
|
title="حذف سجل الصيانة"
|
||||||
/>
|
description={`هل أنت متأكد من حذف سجل ${deleteTarget?.reason ?? ""} (${deleteTarget ? fmtCost(deleteTarget.cost) : ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
<ArchivedCarsMaintenanceModal
|
<ArchivedCarsMaintenanceModal
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Spinner, Alert, ArchiveButton } from "@/src/Components/UI";
|
import { Spinner, Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||||
import { CarFormModal } from "@/src/Components/car/CarFormModal";
|
import { CarFormModal,CarDetailPanel } from "@/src/Components/car";
|
||||||
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
|
|
||||||
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
|
|
||||||
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
|
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
|
||||||
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
|
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
|
||||||
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
|
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
|
||||||
@@ -283,14 +281,14 @@ export default function CarsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<CarDeleteModal
|
open={!!deleteTarget}
|
||||||
car={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onConfirm={handleDelete}
|
||||||
onConfirm={handleDelete}
|
title="حذف المركبة"
|
||||||
/>
|
description={`هل أنت متأكد من حذف ${deleteTarget?.manufacturer ?? ""} ${deleteTarget?.model ?? ""} (${deleteTarget?.plateLetters ?? ""} ${deleteTarget?.plateNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{maintenanceTarget && (
|
{maintenanceTarget && (
|
||||||
<CarMaintenanceFormModal
|
<CarMaintenanceFormModal
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { Alert, ConfirmDialog, Toast } from "@/src/Components/UI";
|
||||||
|
import { AddressFormModal } from "@/src/Components/Client";
|
||||||
|
import { AddressDetails } from "@/src/Components/Client_Adress/AddressDetails";
|
||||||
|
import { clientAddressService } from "@/src/services/clientAddress.service";
|
||||||
|
import { clientService } from "@/src/services/client.service";
|
||||||
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
|
||||||
|
import type { Client } from "@/src/types/client";
|
||||||
|
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||||
|
import type { ToastNotification } from "@/src/Components/UI";
|
||||||
|
import type {
|
||||||
|
CreateAddressFormValues,
|
||||||
|
UpdateAddressFormValues,
|
||||||
|
} from "@/src/validations/client_address.validator";
|
||||||
|
|
||||||
|
// ─── Page ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function AddressDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const clientId = (params?.clientId ?? params?.id) as string | undefined;
|
||||||
|
const addressId = params?.addressId as string | undefined;
|
||||||
|
|
||||||
|
// ── Data state ───────────────────────────────────────────────────────────
|
||||||
|
const [address, setAddress] = useState<ClientAddress | null>(null);
|
||||||
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Modal state ──────────────────────────────────────────────────────────
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// ── Toast ────────────────────────────────────────────────────────────────
|
||||||
|
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||||
|
|
||||||
|
const notify = (n: ToastNotification) => {
|
||||||
|
setNotification(n);
|
||||||
|
setTimeout(() => setNotification(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Fetch ────────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientId || !addressId) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
clientAddressService.getById(clientId, addressId, getStoredToken()),
|
||||||
|
clientService.getById(clientId, getStoredToken()),
|
||||||
|
])
|
||||||
|
.then(([addrRes, clientRes]) => {
|
||||||
|
const raw = (addrRes as any).data ?? addrRes;
|
||||||
|
setAddress({ ...raw, id: raw.id ?? raw._id });
|
||||||
|
setClient(clientRes?.data);
|
||||||
|
})
|
||||||
|
.catch(() => setError("تعذّر تحميل بيانات العنوان. يرجى المحاولة مجدداً."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [clientId, addressId]);
|
||||||
|
|
||||||
|
// ── Update ───────────────────────────────────────────────────────────────
|
||||||
|
const handleUpdateSubmit = async (
|
||||||
|
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!clientId || !addressId) return false;
|
||||||
|
try {
|
||||||
|
const res = await clientAddressService.update(
|
||||||
|
clientId,
|
||||||
|
addressId,
|
||||||
|
data as UpdateAddressFormValues,
|
||||||
|
getStoredToken()
|
||||||
|
);
|
||||||
|
const raw = (res as any).data ?? res;
|
||||||
|
setAddress({ ...raw, id: raw.id ?? raw._id });
|
||||||
|
notify({ type: "success", message: "تم تحديث العنوان بنجاح." });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر تحديث العنوان.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Delete ───────────────────────────────────────────────────────────────
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!clientId || !addressId || deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await clientAddressService.delete(clientId, addressId, getStoredToken());
|
||||||
|
notify({ type: "success", message: "تم حذف العنوان بنجاح." });
|
||||||
|
// short delay so the toast shows before navigation
|
||||||
|
setTimeout(() => {
|
||||||
|
router.replace(`/dashboard/clients/${clientId}/addresses`);
|
||||||
|
}, 700);
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: "error",
|
||||||
|
message: err instanceof Error ? err.message : "تعذّر حذف العنوان.",
|
||||||
|
});
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Guards ───────────────────────────────────────────────────────────────
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "3px solid var(--color-border)",
|
||||||
|
borderTopColor: "var(--color-brand-600)",
|
||||||
|
animation: "spin 0.7s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !address) {
|
||||||
|
return (
|
||||||
|
<section style={{ padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
<Alert type="error" message={error ?? "لم يتم العثور على العنوان."} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-brand-600)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← رجوع
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toast notification={notification} />
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{editOpen && (
|
||||||
|
<AddressFormModal
|
||||||
|
editAddress={address}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSubmit={handleUpdateSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
loading={deleting}
|
||||||
|
onCancel={() => { if (!deleting) setDeleteOpen(false); }}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
title="حذف العميل"
|
||||||
|
description={`هل أنت متأكد من حذف ${address.label}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
|
{/* ── Page header ── */}
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "1.5rem 2rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/dashboard/clients/${clientId}/addresses`)}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
marginBottom: 12,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
{client ? `${client.name} — العناوين` : "العناوين"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.3em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#2563EB",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
تفاصيل العنوان
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address.label}
|
||||||
|
</h1>
|
||||||
|
{client && (
|
||||||
|
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
|
{client.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid #BFDBFE",
|
||||||
|
background: "#EFF6FF",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#1D4ED8",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
تعديل العنوان
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#DC2626",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14H6L5 6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
<path d="M9 6V4h6v2" />
|
||||||
|
</svg>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Address details component ── */}
|
||||||
|
<AddressDetails address={address} />
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
||||||
import { clientService } from "@/src/services/client.service";
|
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 { Client, ClientFormData } from "@/src/types/client";
|
||||||
import type { ClientAddress } from "@/src/types/client_adresses";
|
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||||
|
|
||||||
import { AddressFormModal, DeleteConfirmModal, ClientFormModal } from "@/src/Components/Client";
|
import { AddressFormModal, ClientFormModal } from "@/src/Components/Client";
|
||||||
import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal";
|
import { ArchivedClientsAddressesModal } from "@/src/Components/Client_Adress/archive/ArchivedClientsAddressesModal";
|
||||||
import type {
|
import type {
|
||||||
CreateAddressFormValues,
|
CreateAddressFormValues,
|
||||||
@@ -443,23 +443,14 @@ export default function ClientAddressesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<DeleteConfirmModal
|
open={!!deleteTarget}
|
||||||
client={
|
loading={deleting}
|
||||||
{
|
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||||
...deleteTarget,
|
onConfirm={handleDeleteConfirm}
|
||||||
name: deleteTarget.label,
|
title="حذف العميل"
|
||||||
email: "",
|
description={`هل أنت متأكد من حذف ${deleteTarget?.label ?? ""}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
phone: "",
|
/>
|
||||||
createdAt: deleteTarget.createdAt,
|
|
||||||
updatedAt: deleteTarget.updatedAt,
|
|
||||||
} as Client
|
|
||||||
}
|
|
||||||
deleting={deleting}
|
|
||||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Client edit modal */}
|
{/* Client edit modal */}
|
||||||
{editingClient && client && (
|
{editingClient && client && (
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
// ── UI components ──────────────────────────────────────────────────────────
|
// ── UI components ──────────────────────────────────────────────────────────
|
||||||
// NOTE: Toast is now imported from the canonical UI barrel, NOT from Client/Toast.
|
// NOTE: Toast is now imported from the canonical UI barrel, NOT from Client/Toast.
|
||||||
import { Alert, Toast, ArchiveButton } from "@/src/Components/UI";
|
import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||||
|
|
||||||
// ── Client-specific components ─────────────────────────────────────────────
|
// ── Client-specific components ─────────────────────────────────────────────
|
||||||
import { useClients } from "@/src/hooks/useClients";
|
import { useClients } from "@/src/hooks/useClients";
|
||||||
import type { Client, ClientFormData } from "@/src/types/client";
|
import type { Client, ClientFormData } from "@/src/types/client";
|
||||||
import { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
|
import { ClientFormModal , ClientTable } from "@/src/Components/Client";
|
||||||
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
|
||||||
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
|
||||||
import { ArchivedClientsModal } from "@/src/Components/Client/archive/ArchivedClientsModal";
|
import { ArchivedClientsModal } from "@/src/Components/Client/archive/ArchivedClientsModal";
|
||||||
|
|
||||||
|
|
||||||
@@ -82,14 +80,14 @@ export default function ClientsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<DeleteConfirmModal
|
open={!!deleteTarget}
|
||||||
client={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
onConfirm={handleDeleteConfirm}
|
||||||
onConfirm={handleDeleteConfirm}
|
title="حذف العميل"
|
||||||
/>
|
description={`هل أنت متأكد من حذف ${deleteTarget?.name ?? ""}؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{/* Archive browser modal */}
|
{/* Archive browser modal */}
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
|
|||||||
@@ -2,18 +2,14 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Spinner } from "@/src/Components/UI";
|
import { ConfirmDialog, Spinner } from "@/src/Components/UI";
|
||||||
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
|
import { DriverFormModal , PhotoCard } from "@/src/Components/Driver";
|
||||||
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
|
|
||||||
import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
|
import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
|
||||||
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
|
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 { 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 { driverService } from "@/src/services";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function fmtDate(iso?: string | null): string {
|
function fmtDate(iso?: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
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;
|
return (new Date(iso).getTime() - Date.now()) <= 90 * 86_400_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -73,10 +67,6 @@ function DetailRow({ label, value, mono = false, warn = false }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function DriverDetailPage() {
|
export default function DriverDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -86,11 +76,9 @@ export default function DriverDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [avatarError, setAvatarError] = useState(false);
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
// Reset avatar error when photo changes after an update
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => setAvatarError(false));
|
queueMicrotask(() => setAvatarError(false));
|
||||||
}, [driver?.photoUrl]);
|
}, [driver?.photoUrl]);
|
||||||
// Toast shown after edit/delete actions on this page
|
|
||||||
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
@@ -102,15 +90,14 @@ export default function DriverDetailPage() {
|
|||||||
setTimeout(() => setToast(null), 4000);
|
setTimeout(() => setToast(null), 4000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Load driver ───────────────────────────────────────────────────────────
|
|
||||||
const loadDriver = useCallback(async () => {
|
const loadDriver = useCallback(async () => {
|
||||||
if (!driverId) return;
|
if (!driverId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await driverService.getById(driverId, token);
|
const data = await driverService.getById(driverId, token);
|
||||||
setDriver((res as unknown as { data: Driver }).data);
|
setDriver(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.");
|
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -120,7 +107,6 @@ export default function DriverDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => { queueMicrotask(loadDriver); }, [loadDriver]);
|
useEffect(() => { queueMicrotask(loadDriver); }, [loadDriver]);
|
||||||
|
|
||||||
// ── Edit submit ───────────────────────────────────────────────────────────
|
|
||||||
const handleEditSubmit = useCallback(
|
const handleEditSubmit = useCallback(
|
||||||
async (payload: CreateDriverPayload | UpdateDriverPayload): Promise<boolean> => {
|
async (payload: CreateDriverPayload | UpdateDriverPayload): Promise<boolean> => {
|
||||||
if (!driver) return false;
|
if (!driver) return false;
|
||||||
@@ -132,13 +118,11 @@ export default function DriverDetailPage() {
|
|||||||
const hasFiles = typedPayload.photo || typedPayload.nationalPhoto || typedPayload.driverCardPhoto;
|
const hasFiles = typedPayload.photo || typedPayload.nationalPhoto || typedPayload.driverCardPhoto;
|
||||||
|
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
// Use multipart — JSON.stringify converts File to {} which fails validation
|
|
||||||
await driverService.updateWithImages(driver.id, typedPayload, token);
|
await driverService.updateWithImages(driver.id, typedPayload, token);
|
||||||
} else {
|
} else {
|
||||||
await driverService.update(driver.id, typedPayload, token);
|
await driverService.update(driver.id, typedPayload, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch to reflect updated status, name, photos, etc.
|
|
||||||
await loadDriver();
|
await loadDriver();
|
||||||
showToast("success", "تم تحديث بيانات السائق بنجاح.");
|
showToast("success", "تم تحديث بيانات السائق بنجاح.");
|
||||||
return true;
|
return true;
|
||||||
@@ -151,7 +135,6 @@ export default function DriverDetailPage() {
|
|||||||
[driver, loadDriver, showToast],
|
[driver, loadDriver, showToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Delete confirm ────────────────────────────────────────────────────────
|
|
||||||
const handleConfirmDelete = useCallback(async () => {
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
if (!driver) return;
|
if (!driver) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@@ -168,7 +151,6 @@ export default function DriverDetailPage() {
|
|||||||
|
|
||||||
const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null;
|
const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null;
|
||||||
|
|
||||||
// ── Render: Loading ───────────────────────────────────────────────────────
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "6rem 0", color: "var(--color-text-muted)" }}>
|
<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) {
|
if (error || !driver) {
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 480, margin: "4rem auto", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "1.5rem", textAlign: "center" }}>
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* ── Toast notification ── */}
|
|
||||||
{toast && (
|
{toast && (
|
||||||
<div style={{
|
<div style={{
|
||||||
borderRadius: "var(--radius-lg)",
|
borderRadius: "var(--radius-lg)",
|
||||||
@@ -215,7 +194,6 @@ export default function DriverDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Page header ── */}
|
|
||||||
<header style={{
|
<header style={{
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
|
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
|
||||||
@@ -294,7 +272,6 @@ export default function DriverDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Content grid ── */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
|
||||||
<SectionCard title="البيانات الشخصية">
|
<SectionCard title="البيانات الشخصية">
|
||||||
<DetailRow label="الاسم الكامل" value={driver.name} />
|
<DetailRow label="الاسم الكامل" value={driver.name} />
|
||||||
@@ -380,14 +357,14 @@ export default function DriverDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleteOpen && (
|
<ConfirmDialog
|
||||||
<DriverDeleteModal
|
open={deleteOpen}
|
||||||
driver={driver}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => setDeleteOpen(false)}
|
||||||
onCancel={() => setDeleteOpen(false)}
|
onConfirm={handleConfirmDelete}
|
||||||
onConfirm={handleConfirmDelete}
|
title="حذف السائق"
|
||||||
/>
|
description={`هل أنت متأكد من حذف ${driver.name} (${driver.phone})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Alert, Spinner, ArchiveButton } from "@/src/Components/UI";
|
import { Alert, Spinner, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||||
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
|
|
||||||
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
|
|
||||||
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
||||||
import { useDrivers } from "@/src/hooks/useDriver";
|
import { useDrivers } from "@/src/hooks/useDriver";
|
||||||
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver";
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -413,14 +411,14 @@ export default function DriversPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Delete modal ── */}
|
{/* ── Delete modal ── */}
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<DriverDeleteModal
|
open={!!deleteTarget}
|
||||||
driver={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onConfirm={handleConfirmDelete}
|
||||||
onConfirm={handleConfirmDelete}
|
title="حذف السائق"
|
||||||
/>
|
description={`هل أنت متأكد من حذف ${deleteTarget?.name ?? ""} (${deleteTarget?.phone ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{/* ── Archive browser modal ── */}
|
{/* ── Archive browser modal ── */}
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
|
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Alert, Spinner, Toast, ArchiveButton } from "@/src/Components/UI";
|
import { Alert, Spinner, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||||
import { OrderFormModal } from "@/src/Components/Order/OrderFormModal";
|
import { ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailBanel";
|
||||||
import { OrderDeleteModal } from "@/src/Components/Order/OrderDeleteModal";
|
|
||||||
import { OrderDetailPanel, ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailBanel";
|
|
||||||
import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal";
|
import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal";
|
||||||
import { useOrders } from "@/src/hooks/useOrder";
|
import { useOrders } from "@/src/hooks/useOrder";
|
||||||
import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order";
|
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 ───────
|
// ── Helpers — copied verbatim from app/dashboard/drivers/page.tsx ───────
|
||||||
|
|
||||||
@@ -441,14 +440,14 @@ export default function OrderComponent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Delete modal ── */}
|
{/* ── Delete modal ── */}
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<OrderDeleteModal
|
open={!!deleteTarget}
|
||||||
order={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onConfirm={handleConfirmDelete}
|
||||||
onConfirm={handleConfirmDelete}
|
title="حذف الطلب"
|
||||||
/>
|
description={`هل أنت متأكد من حذف الطلب ${deleteTarget?.shipmentNumber ?? ""} الخاص بـ ${deleteTarget?.recipientName ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{/* ── Archive browser modal ── */}
|
{/* ── Archive browser modal ── */}
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { RoleTable } from "@/src/Components/role/RoleTable";
|
||||||
import { RoleFormModal } from "@/src/Components/role/RoleFormModal";
|
import { RoleFormModal,RoleDetailModal } from "@/src/Components/role";
|
||||||
import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal";
|
|
||||||
import { DeleteRoleModal } from "@/src/Components/role/DeleteRoleModal";
|
|
||||||
import { Toast } from "@/src/Components/UI";
|
|
||||||
import { useRoles } from "@/src/hooks/useRole";
|
import { useRoles } from "@/src/hooks/useRole";
|
||||||
import { Role, RoleFormData } from "@/src/types/role";
|
import { Role, RoleFormData } from "@/src/types/role";
|
||||||
import { ArchivedRolesModal } from "@/src/Components/role/archive/ArchivedRolesModal";
|
import { ArchivedRolesModal } from "@/src/Components/role/archive/ArchivedRolesModal";
|
||||||
@@ -77,14 +74,14 @@ export default function RolesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<DeleteRoleModal
|
open={!!deleteTarget}
|
||||||
role={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||||
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
onConfirm={handleDeleteConfirm}
|
||||||
onConfirm={handleDeleteConfirm}
|
title="حذف الدور"
|
||||||
/>
|
description={`هل أنت متأكد من حذف دور ${deleteTarget?.name ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{/* Archive browser modal */}
|
{/* Archive browser modal */}
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
||||||
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
|
|
||||||
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
|
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
|
||||||
import { tripService } from "@/src/services/trip.service";
|
import { tripService } from "@/src/services/trip.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
@@ -206,44 +205,42 @@ export default function TripDetailPage() {
|
|||||||
|
|
||||||
// ── Load trip ─────────────────────────────────────────────────────────────
|
// ── Load trip ─────────────────────────────────────────────────────────────
|
||||||
const loadTrip = useCallback(async () => {
|
const loadTrip = useCallback(async () => {
|
||||||
if (!tripId) return;
|
if (!tripId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await tripService.getById(tripId, token);
|
const data = await tripService.getById(tripId, token);
|
||||||
setTrip((res as unknown as { data: Trip }).data);
|
setTrip(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
|
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [tripId]);
|
}, [tripId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(loadTrip);
|
queueMicrotask(loadTrip);
|
||||||
}, [loadTrip]);
|
}, [loadTrip]);
|
||||||
|
|
||||||
// ── Edit submit ───────────────────────────────────────────────────────────
|
// ── Edit submit ───────────────────────────────────────────────────────────
|
||||||
const handleEditSubmit = useCallback(
|
const handleEditSubmit = useCallback(
|
||||||
async (
|
async (
|
||||||
payload: CreateTripPayload | UpdateTripPayload,
|
payload: CreateTripPayload | UpdateTripPayload,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!trip) return false;
|
if (!trip) return false;
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await tripService.update(trip.id, payload as UpdateTripPayload, token);
|
const updated = await tripService.update(trip.id, payload as UpdateTripPayload, token);
|
||||||
const updated = (res as unknown as { data: Trip }).data;
|
setTrip(updated);
|
||||||
setTrip(updated);
|
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||||||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
return true;
|
||||||
return true;
|
} catch (err) {
|
||||||
} catch (err) {
|
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
||||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
},
|
||||||
},
|
[trip, notify],
|
||||||
[trip, notify],
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// ── Delete confirm ────────────────────────────────────────────────────────
|
// ── Delete confirm ────────────────────────────────────────────────────────
|
||||||
const handleConfirmDelete = useCallback(async () => {
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
@@ -558,14 +555,14 @@ export default function TripDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Delete modal ── */}
|
{/* ── Delete modal ── */}
|
||||||
{deleteOpen && (
|
<ConfirmDialog
|
||||||
<TripDeleteModal
|
open={deleteOpen}
|
||||||
trip={trip}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => setDeleteOpen(false)}
|
||||||
onCancel={() => setDeleteOpen(false)}
|
onConfirm={handleConfirmDelete}
|
||||||
onConfirm={handleConfirmDelete}
|
title="حذف الرحلة"
|
||||||
/>
|
description={`هل أنت متأكد من حذف رحلة ${trip.title} (${trip.tripNumber})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -31,10 +31,8 @@ export default function ArchivedTripDetailPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
// Archived trips must go through the archived endpoint —
|
const data = await tripService.getArchivedById(tripId, token);
|
||||||
// the normal /trip/:id endpoint won't return soft-deleted trips.
|
if (!cancelled) setTrip(data);
|
||||||
const res = await tripService.getArchivedById(tripId, token);
|
|
||||||
if (!cancelled) setTrip((res as unknown as { data: Trip }).data);
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("لم يتم العثور على هذه الرحلة في الأرشيف.");
|
if (!cancelled) setError("لم يتم العثور على هذه الرحلة في الأرشيف.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -54,7 +52,6 @@ export default function ArchivedTripDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge case: invalid/missing/unreachable trip ID
|
|
||||||
if (error || !trip) {
|
if (error || !trip) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTrips } from "@/src/hooks/useTrip";
|
import { useTrips } from "@/src/hooks/useTrip";
|
||||||
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
||||||
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
|
|
||||||
import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList";
|
import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList";
|
||||||
import { Alert, Spinner, ArchiveButton } from "@/src/Components/UI";
|
import { Alert, Spinner, ArchiveButton, ConfirmDialog , Toast } from "@/src/Components/UI";
|
||||||
import { Toast } from "@/src/Components/UI/Toast";
|
|
||||||
import type {
|
import type {
|
||||||
Trip,
|
Trip,
|
||||||
TripStatus,
|
TripStatus,
|
||||||
@@ -748,14 +746,14 @@ export default function TripsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Delete modal ── */}
|
{/* ── Delete modal ── */}
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<TripDeleteModal
|
open={!!deleteTarget}
|
||||||
trip={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onConfirm={handleConfirmDelete}
|
||||||
onConfirm={handleConfirmDelete}
|
title="حذف الرحلة"
|
||||||
/>
|
description={`هل أنت متأكد من حذف رحلة ${deleteTarget?.title ?? ""} (${deleteTarget?.tripNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{/* ── Archive browser modal ── */}
|
{/* ── Archive browser modal ── */}
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Toast, ArchiveButton } from "@/src/Components/UI";
|
import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||||
import { UserTable } from "@/src/Components/User/UserTable";
|
import { UserFormModal,UserDetailModal,UserTable } from "@/src/Components/User";
|
||||||
import { UserFormModal } from "@/src/Components/User/UserFormModal";
|
|
||||||
import { UserDetailModal } from "@/src/Components/User/UserDetailModal";
|
|
||||||
import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal";
|
|
||||||
import { useUsers } from "@/src/hooks/useUser";
|
import { useUsers } from "@/src/hooks/useUser";
|
||||||
import type { User, UserFormData } from "@/src/types/user";
|
import type { User, UserFormData } from "@/src/types/user";
|
||||||
import { ArchivedUsersModal } from "@/src/Components/User/archive/Archivedusersmodal";
|
import { ArchivedUsersModal } from "@/src/Components/User/archive/Archivedusersmodal";
|
||||||
@@ -76,16 +73,14 @@ export default function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
{deleteTarget && (
|
<ConfirmDialog
|
||||||
<DeleteConfirmModal
|
open={!!deleteTarget}
|
||||||
user={deleteTarget}
|
loading={deleting}
|
||||||
deleting={deleting}
|
onCancel={() => { if (!deleting) setDeleteTarget(null); }}
|
||||||
onCancel={() => {
|
onConfirm={handleDeleteConfirm}
|
||||||
if (!deleting) setDeleteTarget(null);
|
title="حذف المستخدم"
|
||||||
}}
|
description={`هل أنت متأكد من حذف ${deleteTarget?.name ?? ""}؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
onConfirm={handleDeleteConfirm}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Archive browser modal */}
|
{/* Archive browser modal */}
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
// ⚠️ اتصلح: الـ Navbar والـ footer كانوا برة <html>/<body> وده HTML غير صالح
|
// ⚠️ اتصلح: الـ Navbar والـ footer كانوا برة <html>/<body> وده HTML غير صالح
|
||||||
<html lang="ar" dir="rtl">
|
<html lang="ar" dir="rtl">
|
||||||
<body className="app-shell">
|
<body className="app-shell" suppressHydrationWarning>
|
||||||
{/* الناف بار بيظهر بس لو مفيش يوزر مسجل دخول */}
|
{/* الناف بار بيظهر بس لو مفيش يوزر مسجل دخول */}
|
||||||
<ConditionalNavbar />
|
<ConditionalNavbar />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -78,20 +78,20 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
|||||||
|
|
||||||
// fetch branch details on mount
|
// fetch branch details on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await branchService.getById(branchId, token);
|
const data = await branchService.getById(branchId, token);
|
||||||
if (!cancelled) setBranch(res.data);
|
if (!cancelled) setBranch(data);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("تعذّر تحميل بيانات الفرع. يرجى المحاولة لاحقاً.");
|
if (!cancelled) setError("تعذّر تحميل بيانات الفرع. يرجى المحاولة لاحقاً.");
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [branchId]);
|
}, [branchId]);
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
const fmt = (iso?: string | null) =>
|
const fmt = (iso?: string | null) =>
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import type { Branch } from "@/src/types/branch";
|
|
||||||
|
|
||||||
interface DeleteConfirmModalProps {
|
|
||||||
branch: Branch;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteConfirmModal({ branch, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog" aria-modal="true" aria-labelledby="branch-del-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{ width: "100%", maxWidth: 400, background: "var(--color-surface)", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
{/* icon delete */}
|
|
||||||
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 id="branch-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف الفرع</h2>
|
|
||||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
|
||||||
هل أنت متأكد من حذف فرع <strong style={{ color: "var(--color-text-primary)" }}>{branch.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onCancel} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import { fmtCost } from "@/src/types/carMaintanance";
|
|
||||||
import type { CarMaintenance } from "@/src/types/carMaintanance";
|
|
||||||
|
|
||||||
interface CarMaintenanceDeleteModalProps {
|
|
||||||
record: CarMaintenance;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CarMaintenanceDeleteModal({
|
|
||||||
record,
|
|
||||||
deleting,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: CarMaintenanceDeleteModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog" aria-modal="true" aria-labelledby="maintenance-del-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 60,
|
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%", maxWidth: 420,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
|
||||||
padding: "2rem",
|
|
||||||
display: "flex", flexDirection: "column", gap: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 id="maintenance-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
|
||||||
حذف سجل الصيانة
|
|
||||||
</h2>
|
|
||||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
|
||||||
هل أنت متأكد من حذف سجل{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
|
||||||
{record.reason}
|
|
||||||
</strong>
|
|
||||||
{" "}(
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
|
||||||
{fmtCost(record.cost)}
|
|
||||||
</span>
|
|
||||||
)؟ لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onCancel} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import type { Client } from "@/src/types/client";
|
|
||||||
|
|
||||||
interface DeleteConfirmModalProps {
|
|
||||||
client: Client;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteConfirmModal({
|
|
||||||
client,
|
|
||||||
deleting,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: DeleteConfirmModalProps) {
|
|
||||||
// Close on Escape — identical to User/DeleteConfirmModal
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onCancel();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="del-client-title"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) onCancel();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 60,
|
|
||||||
background: "rgba(15,23,42,0.55)",
|
|
||||||
backdropFilter: "blur(4px)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 400,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
|
||||||
padding: "2rem",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* أيقونة الحذف */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
margin: "0 auto",
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "#FEF2F2",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="#DC2626"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2
|
|
||||||
id="del-client-title"
|
|
||||||
style={{
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
حذف العميل
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
هل أنت متأكد من حذف{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
|
||||||
{client.name}
|
|
||||||
</strong>
|
|
||||||
؟ سيتم حذف جميع عناوينه أيضاً. لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={deleting}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: deleting ? "not-allowed" : "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={deleting}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
background: "#DC2626",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
cursor: deleting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
opacity: deleting ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import type { Driver } from "@/src/types/driver";
|
|
||||||
|
|
||||||
interface DriverDeleteModalProps {
|
|
||||||
driver: Driver;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DriverDeleteModal({ driver, deleting, onCancel, onConfirm }: DriverDeleteModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog" aria-modal="true" aria-labelledby="driver-del-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 60,
|
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%", maxWidth: 420,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
|
||||||
padding: "2rem",
|
|
||||||
display: "flex", flexDirection: "column", gap: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div style={{
|
|
||||||
width: 52, height: 52, margin: "0 auto", borderRadius: "50%",
|
|
||||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
}}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 id="driver-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
|
||||||
حذف السائق
|
|
||||||
</h2>
|
|
||||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
|
||||||
هل أنت متأكد من حذف{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>{driver.name}</strong>
|
|
||||||
{" "}(
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
|
||||||
{driver.phone}
|
|
||||||
</span>
|
|
||||||
)؟ لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onCancel} disabled={deleting}
|
|
||||||
style={{
|
|
||||||
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
|
||||||
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
|
||||||
cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)",
|
|
||||||
}}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
|
||||||
style={{
|
|
||||||
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
|
||||||
border: "none", background: "#DC2626",
|
|
||||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
|
||||||
cursor: deleting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1,
|
|
||||||
}}>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,6 @@ import {
|
|||||||
NATIONAL_ID_TYPE_MAP,
|
NATIONAL_ID_TYPE_MAP,
|
||||||
} from "@/src/types/driver";
|
} from "@/src/types/driver";
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function fmtDate(iso?: string | null): string {
|
function fmtDate(iso?: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
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;
|
return new Date(iso).getTime() - Date.now() <= 90 * 86_400_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function DetailRow({
|
function DetailRow({
|
||||||
label,
|
label,
|
||||||
value,
|
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 }) {
|
function PhotoCard({ url, label }: { url?: string | null; label: string }) {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
@@ -161,8 +153,6 @@ function PhotoCard({ url, label }: { url?: string | null; label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface DriverDetailPanelProps {
|
interface DriverDetailPanelProps {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -170,8 +160,6 @@ interface DriverDetailPanelProps {
|
|||||||
onDelete: (driver: Driver) => void;
|
onDelete: (driver: Driver) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function DriverDetailPanel({
|
export function DriverDetailPanel({
|
||||||
driverId,
|
driverId,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -187,8 +175,8 @@ export function DriverDetailPanel({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await driverService.getById(driverId, token);
|
const data = await driverService.getById(driverId, token);
|
||||||
setDriver((res as unknown as { data: Driver }).data);
|
setDriver(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.");
|
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -208,7 +196,6 @@ export function DriverDetailPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
@@ -220,7 +207,6 @@ export function DriverDetailPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Slide-in panel */}
|
|
||||||
<aside
|
<aside
|
||||||
aria-label="تفاصيل السائق"
|
aria-label="تفاصيل السائق"
|
||||||
style={{
|
style={{
|
||||||
@@ -238,7 +224,6 @@ export function DriverDetailPanel({
|
|||||||
overflowY: "hidden",
|
overflowY: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "1.25rem 1.5rem",
|
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", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
{/* Avatar — key={photoUrl} forces remount on photo change */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 56,
|
width: 56,
|
||||||
@@ -314,7 +298,6 @@ export function DriverDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Scrollable content ── */}
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
|
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
||||||
@@ -338,7 +321,6 @@ export function DriverDetailPanel({
|
|||||||
|
|
||||||
{driver && !loading && (
|
{driver && !loading && (
|
||||||
<>
|
<>
|
||||||
{/* Status badges */}
|
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||||
{statusConfig && (
|
{statusConfig && (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -469,7 +451,6 @@ export function DriverDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Footer actions ── */}
|
|
||||||
{driver && (
|
{driver && (
|
||||||
<div
|
<div
|
||||||
style={{
|
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 }) {
|
function AvatarImg({ src, name }: { src: string; name: string }) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -72,27 +72,27 @@ export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverD
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// fetch archived driver details + status history on mount
|
// fetch archived driver details + status history on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const [driverRes, historyRes] = await Promise.all([
|
const [driverData, historyList] = await Promise.all([
|
||||||
archivedDriverService.getById(driverId, token),
|
archivedDriverService.getByIdUnwrapped(driverId, token),
|
||||||
archivedDriverService.getStatusHistory(driverId, token),
|
archivedDriverService.getStatusHistoryUnwrapped(driverId, token),
|
||||||
]);
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setDriver(driverRes.data);
|
setDriver(driverData);
|
||||||
setHistory(historyRes.data ?? []);
|
setHistory(historyList ?? []);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setError("تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً.");
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
}
|
||||||
})();
|
} catch {
|
||||||
return () => { cancelled = true; };
|
if (!cancelled) setError("تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً.");
|
||||||
}, [driverId]);
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [driverId]);
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
const fmt = (iso?: string | null) =>
|
const fmt = (iso?: string | null) =>
|
||||||
|
|||||||
@@ -5,14 +5,10 @@ import { Spinner } from "../UI";
|
|||||||
import { driverService } from "@/src/services/driver.service";
|
import { driverService } from "@/src/services/driver.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface DriverReportPanelProps {
|
interface DriverReportPanelProps {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const [date, setDate] = useState(today);
|
const [date, setDate] = useState(today);
|
||||||
@@ -25,15 +21,10 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await driverService.getDailyReport(driverId, date, token);
|
const { reportUrl } = 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;
|
|
||||||
|
|
||||||
if (!reportUrl) throw new Error("لم يتم إرجاع رابط التقرير.");
|
if (!reportUrl) throw new Error("لم يتم إرجاع رابط التقرير.");
|
||||||
|
|
||||||
// Navigate directly to the report URL
|
|
||||||
window.location.href = reportUrl;
|
window.location.href = reportUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "تعذّر إنشاء التقرير.");
|
setError(err instanceof Error ? err.message : "تعذّر إنشاء التقرير.");
|
||||||
@@ -52,7 +43,6 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
|||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Heading */}
|
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -74,7 +64,6 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Date input */}
|
|
||||||
<div style={{ flex: 1, minWidth: 140 }}>
|
<div style={{ flex: 1, minWidth: 140 }}>
|
||||||
<label
|
<label
|
||||||
htmlFor="report-date"
|
htmlFor="report-date"
|
||||||
@@ -112,7 +101,6 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generate button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
@@ -141,7 +129,6 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import type { Order } from "@/src/types/order";
|
|
||||||
|
|
||||||
interface OrderDeleteModalProps {
|
|
||||||
order: Order;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors DriverDeleteModal.tsx 1:1 — same alertdialog role, same
|
|
||||||
* backdrop/click-outside-to-close behavior, same Escape-to-close effect,
|
|
||||||
* same button layout. Only the copy and the identifying fields differ.
|
|
||||||
*/
|
|
||||||
export function OrderDeleteModal({ order, deleting, onCancel, onConfirm }: OrderDeleteModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog" aria-modal="true" aria-labelledby="order-del-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 60,
|
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%", maxWidth: 420,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
|
||||||
padding: "2rem",
|
|
||||||
display: "flex", flexDirection: "column", gap: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div style={{
|
|
||||||
width: 52, height: 52, margin: "0 auto", borderRadius: "50%",
|
|
||||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
}}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 id="order-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
|
||||||
حذف الطلب
|
|
||||||
</h2>
|
|
||||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
|
||||||
هل أنت متأكد من حذف الطلب{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>{order.shipmentNumber}</strong>
|
|
||||||
{" "}الخاص بـ{" "}
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
|
||||||
{order.recipientName}
|
|
||||||
</span>
|
|
||||||
؟ لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onCancel} disabled={deleting}
|
|
||||||
style={{
|
|
||||||
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
|
||||||
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
|
||||||
cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)",
|
|
||||||
}}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
|
||||||
style={{
|
|
||||||
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
|
||||||
border: "none", background: "#DC2626",
|
|
||||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
|
||||||
cursor: deleting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1,
|
|
||||||
}}>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,7 @@ import { Spinner, Alert } from "../UI";
|
|||||||
import { orderService } from "@/src/services/order.service";
|
import { orderService } from "@/src/services/order.service";
|
||||||
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
||||||
|
|
||||||
// ── Status config ────────────────────────────────────────────────────────
|
const ORDER_STATUS_MAP: Record<OrderStatus, { label: string; color: string; bg: string; border: string; dot: string }> = {
|
||||||
// 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 }
|
|
||||||
> = {
|
|
||||||
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
|
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
|
||||||
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE", dot: "#8B5CF6" },
|
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE", dot: "#8B5CF6" },
|
||||||
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
||||||
@@ -33,7 +27,6 @@ const PAY_METHOD_LABEL: Record<string, string> = {
|
|||||||
Prepaid: "مدفوع مسبقاً",
|
Prepaid: "مدفوع مسبقاً",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers — identical to DriverDetailPanel.tsx's fmtDate ──────────────
|
|
||||||
function fmtDate(iso?: string | null): string {
|
function fmtDate(iso?: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
@@ -50,11 +43,6 @@ function fmtAmount(n?: number | string | null): string {
|
|||||||
return `${num.toFixed(2)} ر.س`;
|
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({
|
function DetailRow({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -114,22 +102,14 @@ function SectionHeading({ title }: { title: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface OrderDetailPanelProps {
|
interface OrderDetailPanelProps {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEdit: (order: Order) => void;
|
onEdit: (order: Order) => void;
|
||||||
onDelete: (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;
|
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({
|
export function OrderDetailPanel({
|
||||||
orderId,
|
orderId,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -141,7 +121,6 @@ export function OrderDetailPanel({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Inline status-update control state
|
|
||||||
const [statusDraft, setStatusDraft] = useState<OrderStatus | "">("");
|
const [statusDraft, setStatusDraft] = useState<OrderStatus | "">("");
|
||||||
const [statusReason, setStatusReason] = useState("");
|
const [statusReason, setStatusReason] = useState("");
|
||||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||||
@@ -151,8 +130,7 @@ export function OrderDetailPanel({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await orderService.getById(orderId);
|
const data = await orderService.getById(orderId);
|
||||||
const data = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
|
||||||
setOrder(data);
|
setOrder(data);
|
||||||
setStatusDraft(data.currentStatus);
|
setStatusDraft(data.currentStatus);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -170,7 +148,6 @@ export function OrderDetailPanel({
|
|||||||
return () => window.removeEventListener("keydown", h);
|
return () => window.removeEventListener("keydown", h);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// ── Status update — alert shown inline in the panel, success bubbled up ──
|
|
||||||
const handleStatusUpdate = useCallback(async () => {
|
const handleStatusUpdate = useCallback(async () => {
|
||||||
if (!order || !statusDraft || statusDraft === order.currentStatus) return;
|
if (!order || !statusDraft || statusDraft === order.currentStatus) return;
|
||||||
setUpdatingStatus(true);
|
setUpdatingStatus(true);
|
||||||
@@ -178,8 +155,7 @@ export function OrderDetailPanel({
|
|||||||
try {
|
try {
|
||||||
const payload: UpdateOrderStatusPayload = { status: statusDraft };
|
const payload: UpdateOrderStatusPayload = { status: statusDraft };
|
||||||
if (statusReason) payload.reason = statusReason;
|
if (statusReason) payload.reason = statusReason;
|
||||||
const res = await orderService.updateStatus(order.id, payload);
|
const updated = await orderService.updateStatus(order.id, payload);
|
||||||
const updated = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
|
||||||
setOrder(updated);
|
setOrder(updated);
|
||||||
setStatusReason("");
|
setStatusReason("");
|
||||||
onStatusChanged?.(updated);
|
onStatusChanged?.(updated);
|
||||||
@@ -195,7 +171,6 @@ export function OrderDetailPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
@@ -207,7 +182,6 @@ export function OrderDetailPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Slide-in panel */}
|
|
||||||
<aside
|
<aside
|
||||||
aria-label="تفاصيل الطلب"
|
aria-label="تفاصيل الطلب"
|
||||||
style={{
|
style={{
|
||||||
@@ -225,7 +199,6 @@ export function OrderDetailPanel({
|
|||||||
overflowY: "hidden",
|
overflowY: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "1.25rem 1.5rem",
|
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", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", 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
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 56,
|
width: 56,
|
||||||
@@ -298,7 +269,6 @@ export function OrderDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Scrollable content ── */}
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
|
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
||||||
@@ -322,7 +292,6 @@ export function OrderDetailPanel({
|
|||||||
|
|
||||||
{order && !loading && (
|
{order && !loading && (
|
||||||
<>
|
<>
|
||||||
{/* Status badges */}
|
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||||
{statusConfig && (
|
{statusConfig && (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -375,7 +344,6 @@ export function OrderDetailPanel({
|
|||||||
<DetailRow label="ضريبة القيمة المضافة" value={fmtAmount(order.vatAmount)} mono />
|
<DetailRow label="ضريبة القيمة المضافة" value={fmtAmount(order.vatAmount)} mono />
|
||||||
<DetailRow label="الإجمالي" value={fmtAmount(order.totalPrice)} mono />
|
<DetailRow label="الإجمالي" value={fmtAmount(order.totalPrice)} mono />
|
||||||
|
|
||||||
{/* ── Delivery Address ── */}
|
|
||||||
{order.deliveryAddress?.details && Object.values(order.deliveryAddress.details).some(Boolean) && (
|
{order.deliveryAddress?.details && Object.values(order.deliveryAddress.details).some(Boolean) && (
|
||||||
<>
|
<>
|
||||||
<SectionHeading title="عنوان التسليم" />
|
<SectionHeading title="عنوان التسليم" />
|
||||||
@@ -394,7 +362,6 @@ export function OrderDetailPanel({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Pickup Address ── */}
|
|
||||||
{order.pickupAddress?.details && Object.values(order.pickupAddress.details).some(Boolean) && (
|
{order.pickupAddress?.details && Object.values(order.pickupAddress.details).some(Boolean) && (
|
||||||
<>
|
<>
|
||||||
<SectionHeading title="عنوان الاستلام" />
|
<SectionHeading title="عنوان الاستلام" />
|
||||||
@@ -416,11 +383,6 @@ export function OrderDetailPanel({
|
|||||||
<SectionHeading title="معلومات النظام" /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
|
<SectionHeading title="معلومات النظام" /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
|
||||||
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
|
<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="تحديث الحالة" />
|
<SectionHeading title="تحديث الحالة" />
|
||||||
{statusError && (
|
{statusError && (
|
||||||
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
||||||
@@ -519,7 +481,6 @@ export function OrderDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Footer actions ── */}
|
|
||||||
{order && (
|
{order && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import type { Client } from "@/src/types/client";
|
|||||||
import type { Trip } from "@/src/types/trip";
|
import type { Trip } from "@/src/types/trip";
|
||||||
import type { OrderSchemaErrors } from "@/src/validations/order.validation";
|
import type { OrderSchemaErrors } from "@/src/validations/order.validation";
|
||||||
|
|
||||||
// ── Shared input / label styles — verbatim from DriverFormModal.tsx ──────────
|
|
||||||
|
|
||||||
const inputBase: React.CSSProperties = {
|
const inputBase: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -68,7 +66,6 @@ const optionalLabelStyle: React.CSSProperties = {
|
|||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Small "Create new" link rendered below each relation dropdown. */
|
|
||||||
const createLinkStyle: React.CSSProperties = {
|
const createLinkStyle: React.CSSProperties = {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: "var(--color-brand-600)",
|
color: "var(--color-brand-600)",
|
||||||
@@ -80,8 +77,6 @@ const createLinkStyle: React.CSSProperties = {
|
|||||||
gap: 3,
|
gap: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Props ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface OrderFormModalProps {
|
interface OrderFormModalProps {
|
||||||
editOrder: Order | null;
|
editOrder: Order | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -91,8 +86,6 @@ interface OrderFormModalProps {
|
|||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function OrderFormModal({
|
export function OrderFormModal({
|
||||||
editOrder,
|
editOrder,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -100,12 +93,10 @@ export function OrderFormModal({
|
|||||||
}: OrderFormModalProps) {
|
}: OrderFormModalProps) {
|
||||||
const isNew = editOrder === null;
|
const isNew = editOrder === null;
|
||||||
|
|
||||||
// ── Relation data — clients & trips loaded once on mount ─────────────────
|
|
||||||
const [clients, setClients] = useState<Client[]>([]);
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
const [trips, setTrips] = useState<Trip[]>([]);
|
const [trips, setTrips] = useState<Trip[]>([]);
|
||||||
const [relLoading, setRelLoading] = useState(true);
|
const [relLoading, setRelLoading] = useState(true);
|
||||||
|
|
||||||
// ── Form state ────────────────────────────────────────────────────────────
|
|
||||||
const [shipmentNumber, setShipmentNumber] = useState(
|
const [shipmentNumber, setShipmentNumber] = useState(
|
||||||
editOrder?.shipmentNumber ?? "",
|
editOrder?.shipmentNumber ?? "",
|
||||||
);
|
);
|
||||||
@@ -135,8 +126,6 @@ export function OrderFormModal({
|
|||||||
editOrder?.paymentStatus ?? "",
|
editOrder?.paymentStatus ?? "",
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Address state ─────────────────────────────────────────────────────────
|
|
||||||
// Delivery address (عنوان التسليم) — always creates new MongoDB doc
|
|
||||||
const [delCity, setDelCity] = useState(
|
const [delCity, setDelCity] = useState(
|
||||||
editOrder?.deliveryAddress?.details?.city ?? "",
|
editOrder?.deliveryAddress?.details?.city ?? "",
|
||||||
);
|
);
|
||||||
@@ -155,7 +144,6 @@ export function OrderFormModal({
|
|||||||
const [delZipCode, setDelZipCode] = useState(
|
const [delZipCode, setDelZipCode] = useState(
|
||||||
editOrder?.deliveryAddress?.details?.zipCode ?? "",
|
editOrder?.deliveryAddress?.details?.zipCode ?? "",
|
||||||
);
|
);
|
||||||
// GeoJSON coordinates [longitude, latitude] — required by backend
|
|
||||||
const [delLng, setDelLng] = useState(
|
const [delLng, setDelLng] = useState(
|
||||||
editOrder?.deliveryAddress?.location?.coordinates?.[0] != null
|
editOrder?.deliveryAddress?.location?.coordinates?.[0] != null
|
||||||
? String(editOrder.deliveryAddress!.location!.coordinates![0])
|
? String(editOrder.deliveryAddress!.location!.coordinates![0])
|
||||||
@@ -167,7 +155,6 @@ export function OrderFormModal({
|
|||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pickup address (عنوان الاستلام) — إما ID موجود أو object جديد
|
|
||||||
const [pickupMode, setPickupMode] = useState<"id" | "new">(
|
const [pickupMode, setPickupMode] = useState<"id" | "new">(
|
||||||
editOrder?.pickupAddressId ? "id" : "new",
|
editOrder?.pickupAddressId ? "id" : "new",
|
||||||
);
|
);
|
||||||
@@ -192,7 +179,6 @@ export function OrderFormModal({
|
|||||||
const [pkpZipCode, setPkpZipCode] = useState(
|
const [pkpZipCode, setPkpZipCode] = useState(
|
||||||
editOrder?.pickupAddress?.details?.zipCode ?? "",
|
editOrder?.pickupAddress?.details?.zipCode ?? "",
|
||||||
);
|
);
|
||||||
// GeoJSON coordinates [longitude, latitude] — optional for pickup
|
|
||||||
const [pkpLng, setPkpLng] = useState(
|
const [pkpLng, setPkpLng] = useState(
|
||||||
editOrder?.pickupAddress?.location?.coordinates?.[0] != null
|
editOrder?.pickupAddress?.location?.coordinates?.[0] != null
|
||||||
? String(editOrder.pickupAddress!.location!.coordinates![0])
|
? String(editOrder.pickupAddress!.location!.coordinates![0])
|
||||||
@@ -209,36 +195,20 @@ export function OrderFormModal({
|
|||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
const firstRef = useRef<HTMLInputElement>(null);
|
const firstRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// ── Load clients + trips in parallel on mount ─────────────────────────────
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
|
|
||||||
// Fetch first 100 clients (enough for a practical dropdown)
|
const { items: clientList } = await clientService.getAll(1, "", token);
|
||||||
const clientRes = await clientService.getAll(1, "", token);
|
const { items: tripList } = await tripService.getAll({ page: 1, limit: 100 }, 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 ?? [];
|
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setClients(clientList);
|
setClients(clientList);
|
||||||
setTrips(tripList);
|
setTrips(tripList);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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);
|
console.error("OrderFormModal: failed to load clients/trips", err);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setApiError(
|
setApiError(
|
||||||
@@ -254,7 +224,6 @@ export function OrderFormModal({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Keyboard / focus setup ────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
firstRef.current?.focus();
|
firstRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -266,7 +235,6 @@ export function OrderFormModal({
|
|||||||
return () => window.removeEventListener("keydown", h);
|
return () => window.removeEventListener("keydown", h);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// ── Dynamic input error style ─────────────────────────────────────────────
|
|
||||||
const inputStyle = (field: keyof OrderSchemaErrors): React.CSSProperties => ({
|
const inputStyle = (field: keyof OrderSchemaErrors): React.CSSProperties => ({
|
||||||
...inputBase,
|
...inputBase,
|
||||||
border: errors[field] ? "1px solid var(--color-danger)" : inputBase.border,
|
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 runValidation = useCallback(async (): Promise<boolean> => {
|
||||||
const schema = isNew ? createOrderSchema : updateOrderSchema;
|
const schema = isNew ? createOrderSchema : updateOrderSchema;
|
||||||
try {
|
try {
|
||||||
@@ -326,7 +293,6 @@ export function OrderFormModal({
|
|||||||
type,
|
type,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Submit ────────────────────────────────────────────────────────────────
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -334,10 +300,6 @@ export function OrderFormModal({
|
|||||||
const valid = await runValidation();
|
const valid = await runValidation();
|
||||||
if (!valid) return;
|
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> = {
|
const payload: Record<string, unknown> = {
|
||||||
shipmentNumber,
|
shipmentNumber,
|
||||||
recipientName,
|
recipientName,
|
||||||
@@ -346,10 +308,8 @@ export function OrderFormModal({
|
|||||||
quantity: Number(quantity) || 1,
|
quantity: Number(quantity) || 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
// tripId: only include when a value is selected
|
|
||||||
if (tripId) payload.tripId = tripId;
|
if (tripId) payload.tripId = tripId;
|
||||||
|
|
||||||
// Optional fields — include only when filled
|
|
||||||
if (type) payload.type = type;
|
if (type) payload.type = type;
|
||||||
if (weight) payload.weight = Number(weight);
|
if (weight) payload.weight = Number(weight);
|
||||||
if (subTotal) payload.subTotal = Number(subTotal);
|
if (subTotal) payload.subTotal = Number(subTotal);
|
||||||
@@ -357,7 +317,6 @@ export function OrderFormModal({
|
|||||||
if (paymentMethod) payload.paymentMethod = paymentMethod;
|
if (paymentMethod) payload.paymentMethod = paymentMethod;
|
||||||
if (paymentStatus) payload.paymentStatus = paymentStatus;
|
if (paymentStatus) payload.paymentStatus = paymentStatus;
|
||||||
|
|
||||||
// ── Build delivery address object (only if at least one field filled) ──
|
|
||||||
const delDetails = {
|
const delDetails = {
|
||||||
...(delCity && { city: delCity }),
|
...(delCity && { city: delCity }),
|
||||||
...(delDistrict && { district: delDistrict }),
|
...(delDistrict && { district: delDistrict }),
|
||||||
@@ -366,7 +325,6 @@ export function OrderFormModal({
|
|||||||
...(delUnitNo && { unitNo: delUnitNo }),
|
...(delUnitNo && { unitNo: delUnitNo }),
|
||||||
...(delZipCode && { zipCode: delZipCode }),
|
...(delZipCode && { zipCode: delZipCode }),
|
||||||
};
|
};
|
||||||
// coordinates are always included when provided (required by backend GeoJSON schema)
|
|
||||||
const delCoordinates =
|
const delCoordinates =
|
||||||
delLng.trim() && delLat.trim()
|
delLng.trim() && delLat.trim()
|
||||||
? [Number(delLng), Number(delLat)]
|
? [Number(delLng), Number(delLat)]
|
||||||
@@ -379,7 +337,6 @@ export function OrderFormModal({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build pickup address: ID or new object ──
|
|
||||||
if (pickupMode === "id" && pickupAddressId.trim()) {
|
if (pickupMode === "id" && pickupAddressId.trim()) {
|
||||||
payload.pickupAddressId = pickupAddressId.trim();
|
payload.pickupAddressId = pickupAddressId.trim();
|
||||||
} else {
|
} else {
|
||||||
@@ -428,7 +385,6 @@ export function OrderFormModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -463,7 +419,6 @@ export function OrderFormModal({
|
|||||||
margin: "auto",
|
margin: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -522,7 +477,6 @@ export function OrderFormModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Form ── */}
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
@@ -544,7 +498,6 @@ export function OrderFormModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Section: Shipment & Recipient ── */}
|
|
||||||
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -554,7 +507,6 @@ export function OrderFormModal({
|
|||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Shipment number — required */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
رقم الشحنة *
|
رقم الشحنة *
|
||||||
<input
|
<input
|
||||||
@@ -574,14 +526,12 @@ export function OrderFormModal({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* ── Client dropdown — required ── */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
العميل *
|
العميل *
|
||||||
<select
|
<select
|
||||||
style={{
|
style={{
|
||||||
...inputStyle("clientId"),
|
...inputStyle("clientId"),
|
||||||
cursor: relLoading ? "wait" : "pointer",
|
cursor: relLoading ? "wait" : "pointer",
|
||||||
// Shift placeholder text right for RTL
|
|
||||||
paddingRight: "0.75rem",
|
paddingRight: "0.75rem",
|
||||||
}}
|
}}
|
||||||
value={clientId}
|
value={clientId}
|
||||||
@@ -605,7 +555,6 @@ export function OrderFormModal({
|
|||||||
{errors.clientId && (
|
{errors.clientId && (
|
||||||
<span style={errorTextStyle}>{errors.clientId}</span>
|
<span style={errorTextStyle}>{errors.clientId}</span>
|
||||||
)}
|
)}
|
||||||
{/* Helper link */}
|
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/clients"
|
href="/dashboard/clients"
|
||||||
style={createLinkStyle}
|
style={createLinkStyle}
|
||||||
@@ -626,7 +575,6 @@ export function OrderFormModal({
|
|||||||
</Link>
|
</Link>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Recipient name — required */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
اسم المستلم *
|
اسم المستلم *
|
||||||
<input
|
<input
|
||||||
@@ -644,7 +592,6 @@ export function OrderFormModal({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Recipient phone — required */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
رقم جوال المستلم *
|
رقم جوال المستلم *
|
||||||
<input
|
<input
|
||||||
@@ -662,7 +609,6 @@ export function OrderFormModal({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* ── Trip dropdown — required on create, optional on edit ── */}
|
|
||||||
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
|
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
|
||||||
{isNew ? "الرحلة *" : "الرحلة"}
|
{isNew ? "الرحلة *" : "الرحلة"}
|
||||||
{!isNew && <span style={optionalLabelStyle}>(اختياري)</span>}
|
{!isNew && <span style={optionalLabelStyle}>(اختياري)</span>}
|
||||||
@@ -693,7 +639,6 @@ export function OrderFormModal({
|
|||||||
{errors.tripId && (
|
{errors.tripId && (
|
||||||
<span style={errorTextStyle}>{errors.tripId}</span>
|
<span style={errorTextStyle}>{errors.tripId}</span>
|
||||||
)}
|
)}
|
||||||
{/* Helper link */}
|
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/trips"
|
href="/dashboard/trips"
|
||||||
style={createLinkStyle}
|
style={createLinkStyle}
|
||||||
@@ -715,7 +660,6 @@ export function OrderFormModal({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Section: Shipment details ── */}
|
|
||||||
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
|
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -725,7 +669,6 @@ export function OrderFormModal({
|
|||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Type — optional */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
نوع الشحنة
|
نوع الشحنة
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||||
@@ -738,7 +681,6 @@ export function OrderFormModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Quantity — required, defaults to 1 */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
الكمية *
|
الكمية *
|
||||||
<input
|
<input
|
||||||
@@ -757,7 +699,6 @@ export function OrderFormModal({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Weight — optional */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
الوزن (كجم)
|
الوزن (كجم)
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||||
@@ -773,7 +714,6 @@ export function OrderFormModal({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Section: Payment ── */}
|
|
||||||
<p style={sectionHeadingStyle}>بيانات الدفع</p>
|
<p style={sectionHeadingStyle}>بيانات الدفع</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -783,7 +723,6 @@ export function OrderFormModal({
|
|||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Subtotal — optional */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
الإجمالي الفرعي
|
الإجمالي الفرعي
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||||
@@ -804,7 +743,6 @@ export function OrderFormModal({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* VAT rate — optional */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
نسبة الضريبة (%)
|
نسبة الضريبة (%)
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||||
@@ -825,7 +763,6 @@ export function OrderFormModal({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Payment method — optional */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
طريقة الدفع
|
طريقة الدفع
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||||
@@ -844,7 +781,6 @@ export function OrderFormModal({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Payment status — edit-only, mirrors Driver's status-on-edit pattern */}
|
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
حالة الدفع
|
حالة الدفع
|
||||||
@@ -866,7 +802,6 @@ export function OrderFormModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Section: Delivery Address ── */}
|
|
||||||
<p style={sectionHeadingStyle}>عنوان التسليم</p>
|
<p style={sectionHeadingStyle}>عنوان التسليم</p>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -941,7 +876,6 @@ export function OrderFormModal({
|
|||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{/* GeoJSON coordinates — required by backend */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
خط الطول (Longitude) *
|
خط الطول (Longitude) *
|
||||||
<input
|
<input
|
||||||
@@ -968,10 +902,8 @@ export function OrderFormModal({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Section: Pickup Address ── */}
|
|
||||||
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
|
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
|
||||||
|
|
||||||
{/* Toggle: existing ID or new address */}
|
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}
|
||||||
>
|
>
|
||||||
@@ -1090,7 +1022,6 @@ export function OrderFormModal({
|
|||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{/* GeoJSON coordinates — optional for pickup */}
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
خط الطول (Longitude)
|
خط الطول (Longitude)
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||||
@@ -1120,7 +1051,6 @@ export function OrderFormModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Actions ── */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
2
src/Components/Order/index.ts
Normal file
2
src/Components/Order/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export {OrderFormModal} from './OrderFormModal';
|
||||||
|
export {OrderDetailPanel} from './OrderDetailBanel';
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import type { Trip } from "@/src/types/trip";
|
|
||||||
|
|
||||||
interface TripDeleteModalProps {
|
|
||||||
trip: Trip;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TripDeleteModal({ trip, deleting, onCancel, onConfirm }: TripDeleteModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog" aria-modal="true" aria-labelledby="trip-del-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 60,
|
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%", maxWidth: 420,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
|
||||||
padding: "2rem",
|
|
||||||
display: "flex", flexDirection: "column", gap: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div style={{
|
|
||||||
width: 52, height: 52, margin: "0 auto", borderRadius: "50%",
|
|
||||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
}}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 id="trip-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
|
||||||
حذف الرحلة
|
|
||||||
</h2>
|
|
||||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
|
||||||
هل أنت متأكد من حذف رحلة{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>{trip.title}</strong>
|
|
||||||
{" "}(
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
|
||||||
{trip.tripNumber}
|
|
||||||
</span>
|
|
||||||
)؟ لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onCancel} disabled={deleting}
|
|
||||||
style={{
|
|
||||||
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
|
||||||
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
|
||||||
cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)",
|
|
||||||
}}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
|
||||||
style={{
|
|
||||||
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
|
||||||
border: "none", background: "#DC2626",
|
|
||||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
|
||||||
cursor: deleting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1,
|
|
||||||
}}>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import { Spinner } from "../UI";
|
import { Spinner } from "../UI";
|
||||||
import { get } from "@/src/services/api";
|
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import {
|
import {
|
||||||
createTripSchema,
|
createTripSchema,
|
||||||
@@ -16,6 +15,12 @@ import type {
|
|||||||
CreateTripPayload,
|
CreateTripPayload,
|
||||||
UpdateTripPayload,
|
UpdateTripPayload,
|
||||||
} from "@/src/types/trip";
|
} 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 ────────────────────────────────────────────────────────────
|
// ── Shared styles ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -63,25 +68,9 @@ const optionalLabelStyle: React.CSSProperties = {
|
|||||||
marginRight: 4,
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -108,35 +97,12 @@ export function TripFormModal({
|
|||||||
const [cars, setCars] = useState<CarOption[]>([]);
|
const [cars, setCars] = useState<CarOption[]>([]);
|
||||||
const [branches, setBranches] = useState<BranchOption[]>([]);
|
const [branches, setBranches] = useState<BranchOption[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
get<{ data: { data: DriverOption[] } }>(
|
driverService.getActiveOptions(token).then(setDrivers).catch(() => {});
|
||||||
"driver?limit=100&status=Active",
|
carService.getActiveOptions(token).then(setCars).catch(() => {});
|
||||||
token,
|
branchService.getOptions(token).then(setBranches).catch(() => {});
|
||||||
)
|
}, []);
|
||||||
.then((res) =>
|
|
||||||
setDrivers(
|
|
||||||
(res as unknown as { data: { data: DriverOption[] } }).data?.data ??
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
get<{ data: { data: CarOption[] } }>("cars?limit=100¤tStatus=Active", token)
|
|
||||||
.then((res) =>
|
|
||||||
setCars(
|
|
||||||
(res as unknown as { data: { data: CarOption[] } }).data?.data ?? [],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
get<{ data: { data: BranchOption[] } }>("branches?limit=100", token)
|
|
||||||
.then((res) =>
|
|
||||||
setBranches(
|
|
||||||
(res as unknown as { data: { data: BranchOption[] } }).data?.data ??
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
//console.log("cars", cars);
|
//console.log("cars", cars);
|
||||||
// ── Form state ────────────────────────────────────────────────────────────
|
// ── Form state ────────────────────────────────────────────────────────────
|
||||||
const [title, setTitle] = useState(editTrip?.title ?? "");
|
const [title, setTitle] = useState(editTrip?.title ?? "");
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Spinner } from "../UI";
|
import { Spinner ,Toast} from "../UI";
|
||||||
import { Toast } from "../UI/Toast";
|
|
||||||
import { tripService } from "@/src/services/trip.service";
|
import { tripService } from "@/src/services/trip.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import type { TripNotification } from "@/src/hooks/useTrip";
|
import type { TripNotification } from "@/src/hooks/useTrip";
|
||||||
@@ -11,7 +10,6 @@ interface TripReportPanelProps {
|
|||||||
tripId: string;
|
tripId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extracts a readable message from any error shape the API might return.
|
|
||||||
function extractApiMessage(err: unknown, fallback: string): string {
|
function extractApiMessage(err: unknown, fallback: string): string {
|
||||||
if (err && typeof err === "object") {
|
if (err && typeof err === "object") {
|
||||||
const e = err as Record<string, unknown>;
|
const e = err as Record<string, unknown>;
|
||||||
@@ -40,15 +38,14 @@ export function TripReportPanel({ tripId }: TripReportPanelProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await tripService.getReport(tripId, token);
|
const { reportUrl } = await tripService.getReport(tripId, token);
|
||||||
const url = (res as unknown as { data: { reportUrl: string } }).data?.reportUrl;
|
|
||||||
|
|
||||||
if (!url) {
|
if (!reportUrl) {
|
||||||
notify({ type: "error", message: "لم يتم إرجاع رابط التقرير من الخادم." });
|
notify({ type: "error", message: "لم يتم إرجاع رابط التقرير من الخادم." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(reportUrl, "_blank", "noopener,noreferrer");
|
||||||
notify({ type: "success", message: "تم إنشاء التقرير بنجاح." });
|
notify({ type: "success", message: "تم إنشاء التقرير بنجاح." });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر إنشاء التقرير.") });
|
notify({ type: "error", message: extractApiMessage(err, "تعذّر إنشاء التقرير.") });
|
||||||
@@ -107,7 +104,6 @@ export function TripReportPanel({ tripId }: TripReportPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toast scoped to this panel — sits at the bottom of the screen */}
|
|
||||||
<Toast
|
<Toast
|
||||||
notification={notification}
|
notification={notification}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import type { User } from "@/src/types/user";
|
|
||||||
|
|
||||||
interface DeleteConfirmModalProps {
|
|
||||||
user: User;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteConfirmModal({ user, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog" aria-modal="true" aria-labelledby="del-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{ width: "100%", maxWidth: 400, background: "var(--color-surface)", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
{/* icon delete */}
|
|
||||||
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 id="del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف المستخدم</h2>
|
|
||||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
|
||||||
هل أنت متأكد من حذف <strong style={{ color: "var(--color-text-primary)" }}>{user.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onCancel} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -77,20 +77,20 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
|||||||
|
|
||||||
// fetch user details on mount
|
// fetch user details on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await userService.getById(userId, token);
|
const data = await userService.getById(userId, token);
|
||||||
if (!cancelled) setUser(res.data);
|
if (!cancelled) setUser(data);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً.");
|
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم. يرجى المحاولة لاحقاً.");
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
const fmt = (iso?: string | null) =>
|
const fmt = (iso?: string | null) =>
|
||||||
|
|||||||
@@ -76,21 +76,21 @@ export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailM
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// fetch archived user details on mount
|
// fetch archived user details on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await archivedUserService.getById(userId, token);
|
const data = await archivedUserService.getById(userId, token);
|
||||||
if (!cancelled) setUser(res.data);
|
if (!cancelled) setUser(data);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم المؤرشف. يرجى المحاولة لاحقاً.");
|
if (!cancelled) setError("تعذّر تحميل بيانات المستخدم المؤرشف. يرجى المحاولة لاحقاً.");
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
const fmt = (iso?: string | null) =>
|
const fmt = (iso?: string | null) =>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import type { Car } from "@/src/types/car";
|
|
||||||
|
|
||||||
interface CarDeleteModalProps {
|
|
||||||
car: Car;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CarDeleteModal({ car, deleting, onCancel, onConfirm }: CarDeleteModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog" aria-modal="true" aria-labelledby="car-del-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 60,
|
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "100%", maxWidth: 420,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
|
||||||
padding: "2rem",
|
|
||||||
display: "flex", flexDirection: "column", gap: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 id="car-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
|
||||||
حذف المركبة
|
|
||||||
</h2>
|
|
||||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
|
||||||
هل أنت متأكد من حذف{" "}
|
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
|
||||||
{car.manufacturer} {car.model}
|
|
||||||
</strong>
|
|
||||||
{" "}(
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
|
||||||
{car.plateLetters} {car.plateNumber}
|
|
||||||
</span>
|
|
||||||
)؟ لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onCancel} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Spinner } from "../UI";
|
||||||
import { get } from "@/src/services/api";
|
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { createCarSchema, updateCarSchema } from "@/src/validations/car.validator";
|
import {
|
||||||
|
createCarSchema,
|
||||||
|
updateCarSchema,
|
||||||
|
} from "@/src/validations/car.validator";
|
||||||
import type {
|
import type {
|
||||||
Car,
|
Car,
|
||||||
CarFormErrors,
|
CarFormErrors,
|
||||||
@@ -14,6 +16,8 @@ import type {
|
|||||||
UpdateCarPayload,
|
UpdateCarPayload,
|
||||||
} from "@/src/types/car";
|
} from "@/src/types/car";
|
||||||
import type { Branch } from "@/src/types/branch";
|
import type { Branch } from "@/src/types/branch";
|
||||||
|
import { branchService } from "@/src/services/branch.service";
|
||||||
|
|
||||||
|
|
||||||
// ── Shared input style ────────────────────────────────────────────────────────
|
// ── Shared input style ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -100,23 +104,22 @@ export function CarFormModal({
|
|||||||
const isNew = editCar === null;
|
const isNew = editCar === null;
|
||||||
|
|
||||||
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||||
const loadBranches = useCallback(() => {
|
const loadBranches = useCallback(() => {
|
||||||
if (branchesProp.length > 0) {
|
if (branchesProp.length > 0) {
|
||||||
queueMicrotask(() => setBranches(branchesProp));
|
queueMicrotask(() => setBranches(branchesProp));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
|
branchService
|
||||||
.then((res) => {
|
.getOptions(token)
|
||||||
const list =
|
.then((list) => {
|
||||||
(res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
|
queueMicrotask(() => setBranches(list as unknown as Branch[]));
|
||||||
queueMicrotask(() => setBranches(list));
|
})
|
||||||
})
|
.catch(() => {
|
||||||
.catch(() => {
|
/* silently ignore */
|
||||||
/* silently ignore */
|
});
|
||||||
});
|
}, [branchesProp]);
|
||||||
}, [branchesProp]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBranches();
|
loadBranches();
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Spinner } from "../UI";
|
|
||||||
import { Role } from "@/src/types/role";
|
|
||||||
|
|
||||||
interface DeleteRoleModalProps {
|
|
||||||
role: Role;
|
|
||||||
deleting: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alertdialog" aria-modal="true" aria-labelledby="del-role-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 60,
|
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div onClick={e => e.stopPropagation()} style={{
|
|
||||||
width: "100%", maxWidth: 400,
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
|
||||||
padding: "2rem",
|
|
||||||
display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center",
|
|
||||||
}}>
|
|
||||||
{/* Icon */}
|
|
||||||
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
||||||
<path d="M10 11v6M14 11v6" />
|
|
||||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 id="del-role-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
|
||||||
حذف الدور
|
|
||||||
</h2>
|
|
||||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
|
||||||
هل أنت متأكد من حذف دور <strong style={{ color: "var(--color-text-primary)" }}>{role.name}</strong>؟
|
|
||||||
لا يمكن التراجع عن هذا الإجراء.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onCancel} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
|
||||||
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
|
||||||
{deleting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import { Permission, Role } from "@/src/types/role";
|
|||||||
|
|
||||||
interface RoleDetailModalProps {
|
interface RoleDetailModalProps {
|
||||||
roleId: string;
|
roleId: string;
|
||||||
permissions: Permission[]; // full catalog, for the "add permission" select
|
permissions: Permission[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAssign: (roleId: string, permissionId: string) => Promise<boolean>;
|
onAssign: (roleId: string, permissionId: string) => Promise<boolean>;
|
||||||
onRemove: (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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Inline permission mutation state ─────────────────────────────────────────
|
|
||||||
const [pendingPermId, setPendingPermId] = useState("");
|
const [pendingPermId, setPendingPermId] = useState("");
|
||||||
const [mutating, setMutating] = useState(false);
|
const [mutating, setMutating] = useState(false);
|
||||||
|
|
||||||
@@ -64,8 +63,8 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
const loadRole = async () => {
|
const loadRole = async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await roleService.getById(roleId, token);
|
const data = await roleService.getById(roleId, token);
|
||||||
setRole((res as unknown as { data: Role }).data);
|
setRole(data);
|
||||||
} catch {
|
} catch {
|
||||||
setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً.");
|
setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,8 +77,8 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await roleService.getById(roleId, token);
|
const data = await roleService.getById(roleId, token);
|
||||||
if (!cancelled) setRole((res as unknown as { data: Role }).data);
|
if (!cancelled) setRole(data);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً.");
|
if (!cancelled) setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,7 +88,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [roleId]);
|
}, [roleId]);
|
||||||
|
|
||||||
// Endpoint 1: POST /role/{id}/permissions
|
|
||||||
const handleAssign = async () => {
|
const handleAssign = async () => {
|
||||||
if (!pendingPermId || mutating) return;
|
if (!pendingPermId || mutating) return;
|
||||||
setMutating(true);
|
setMutating(true);
|
||||||
@@ -101,7 +99,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
setMutating(false);
|
setMutating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Endpoint 3: DELETE /role/{id}/permissions/{permissionId}
|
|
||||||
const handleRemove = async (permissionId: string) => {
|
const handleRemove = async (permissionId: string) => {
|
||||||
if (mutating) return;
|
if (mutating) return;
|
||||||
setMutating(true);
|
setMutating(true);
|
||||||
@@ -135,7 +132,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||||
overflow: "hidden", display: "flex", flexDirection: "column",
|
overflow: "hidden", display: "flex", flexDirection: "column",
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
padding: "1.25rem 1.5rem",
|
padding: "1.25rem 1.5rem",
|
||||||
@@ -156,7 +152,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
|
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<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 && (
|
{!loading && role && (
|
||||||
<div dir="rtl">
|
<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={{ display: "flex", alignItems: "center", gap: "1rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--color-border)", marginBottom: "0.25rem" }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 56, height: 56, borderRadius: "var(--radius-xl)",
|
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)} />
|
<DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
|
||||||
{role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
|
{role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
|
||||||
|
|
||||||
{/* Permissions list */}
|
|
||||||
<div style={{ paddingTop: "0.75rem" }}>
|
<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" }}>
|
<p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 10px" }}>
|
||||||
الصلاحيات ({role.permissions?.length ?? 0})
|
الصلاحيات ({role.permissions?.length ?? 0})
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Assign new permission — Endpoint 1 */}
|
|
||||||
{assignablePermissions.length > 0 && (
|
{assignablePermissions.length > 0 && (
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||||
<select
|
<select
|
||||||
@@ -250,7 +242,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
fontSize: 11, fontWeight: 600, color: "#1D4ED8",
|
fontSize: 11, fontWeight: 600, color: "#1D4ED8",
|
||||||
}}>
|
}}>
|
||||||
{permission.name}
|
{permission.name}
|
||||||
{/* Endpoint 3: remove this permission */}
|
|
||||||
<button
|
<button
|
||||||
type="button" onClick={() => handleRemove(permission.id)} disabled={mutating}
|
type="button" onClick={() => handleRemove(permission.id)} disabled={mutating}
|
||||||
aria-label={`إزالة ${permission.name}`}
|
aria-label={`إزالة ${permission.name}`}
|
||||||
@@ -273,7 +264,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)",
|
padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end",
|
background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end",
|
||||||
|
|||||||
@@ -78,20 +78,20 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
|||||||
|
|
||||||
// fetch archived role details on mount
|
// fetch archived role details on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await archivedRoleService.getById(roleId, token);
|
const data = await archivedRoleService.getByIdUnwrapped(roleId, token);
|
||||||
if (!cancelled) setRole(res.data);
|
if (!cancelled) setRole(data);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("تعذّر تحميل بيانات الدور المؤرشف. يرجى المحاولة لاحقاً.");
|
if (!cancelled) setError("تعذّر تحميل بيانات الدور المؤرشف. يرجى المحاولة لاحقاً.");
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [roleId]);
|
}, [roleId]);
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
const fmt = (iso?: string | null) =>
|
const fmt = (iso?: string | null) =>
|
||||||
|
|||||||
@@ -33,20 +33,19 @@ export function useCarMaintenanceList(
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadRecords = useCallback(() => {
|
const loadRecords = useCallback(() => {
|
||||||
if (!carId) return;
|
if (!carId) return;
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
carMaintenanceService
|
carMaintenanceService
|
||||||
.getAll(carId, token)
|
.getAllUnwrapped(carId, token)
|
||||||
.then((res) => {
|
.then((list) => {
|
||||||
const list = (res as unknown as { data: CarMaintenance[] }).data ?? [];
|
list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
setAllRecords(list);
|
||||||
setAllRecords(list);
|
})
|
||||||
})
|
.catch((err: Error) => setError(err.message))
|
||||||
.catch((err: Error) => setError(err.message))
|
.finally(() => setLoading(false));
|
||||||
.finally(() => setLoading(false));
|
}, [carId]);
|
||||||
}, [carId]);
|
|
||||||
|
|
||||||
useEffect(() => { queueMicrotask(loadRecords); }, [loadRecords]);
|
useEffect(() => { queueMicrotask(loadRecords); }, [loadRecords]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ export function useArchivedCars() {
|
|||||||
carService
|
carService
|
||||||
.getArchived(token)
|
.getArchived(token)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const payload = (res as unknown as { data: { data: Car[] } }).data ?? res;
|
setAllCars(res ?? []);
|
||||||
setAllCars((payload as { data: Car[] }).data ?? []);
|
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
@@ -18,19 +18,18 @@ export function useArchivedClientAddresses(clientId?: string) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await archivedClientAddressService.getAll(token);
|
setAddresses(await archivedClientAddressService.getAllUnwrapped(token));
|
||||||
setAddresses(res.data);
|
setError(null);
|
||||||
setError(null);
|
} catch {
|
||||||
} catch {
|
setError("تعذّر تحميل أرشيف العناوين. يرجى المحاولة لاحقاً.");
|
||||||
setError("تعذّر تحميل أرشيف العناوين. يرجى المحاولة لاحقاً.");
|
} finally {
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
}, []);
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { queueMicrotask(load); }, [load]);
|
useEffect(() => { queueMicrotask(load); }, [load]);
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,7 @@ import type { ArchivedRole } from "@/src/types/role";
|
|||||||
|
|
||||||
const PAGE_SIZE = 10;
|
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() {
|
export function useArchivedRoles() {
|
||||||
// full unfiltered list as returned by the API
|
// full unfiltered list as returned by the API
|
||||||
@@ -33,19 +16,18 @@ export function useArchivedRoles() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await archivedRoleService.getAll(token);
|
setAllRoles(await archivedRoleService.getAllUnwrapped(token));
|
||||||
setAllRoles(extractList(res));
|
setError(null);
|
||||||
setError(null);
|
} catch {
|
||||||
} catch {
|
setError("تعذّر تحميل قائمة الأدوار المؤرشفة. يرجى المحاولة لاحقاً.");
|
||||||
setError("تعذّر تحميل قائمة الأدوار المؤرشفة. يرجى المحاولة لاحقاً.");
|
setAllRoles([]);
|
||||||
setAllRoles([]);
|
} finally {
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
}, []);
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { queueMicrotask(load); }, [load]);
|
useEffect(() => { queueMicrotask(load); }, [load]);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function reducer(s: TableState, a: TableAction): TableState {
|
|||||||
case "LOAD_START":
|
case "LOAD_START":
|
||||||
return { ...s, loading: true, error: null };
|
return { ...s, loading: true, error: null };
|
||||||
case "LOAD_OK":
|
case "LOAD_OK":
|
||||||
return { ...s, loading: false, orders: a.orders };
|
return { ...s, loading: false, orders: Array.isArray(a.orders) ? a.orders : [] };
|
||||||
case "LOAD_ERR":
|
case "LOAD_ERR":
|
||||||
return { ...s, loading: false, error: a.error };
|
return { ...s, loading: false, error: a.error };
|
||||||
case "CLEAR_ERR":
|
case "CLEAR_ERR":
|
||||||
@@ -50,27 +50,15 @@ export function useArchivedOrders() {
|
|||||||
|
|
||||||
// ── Fetch archived orders ─────────────────────────────────────────────
|
// ── Fetch archived orders ─────────────────────────────────────────────
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await archivedOrderService.getAll(token);
|
const orders = await archivedOrderService.getAllUnwrapped(token);
|
||||||
|
dispatch({ type: "LOAD_OK", orders });
|
||||||
// نفس الـ pattern بتاع useOrder.ts:
|
} catch {
|
||||||
// get() بترجع axios response كامل، فـ res.data هو الـ JSON body
|
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل الطلبات المؤرشفة. يرجى المحاولة لاحقاً." });
|
||||||
// {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: "تعذّر تحميل الطلبات المؤرشفة. يرجى المحاولة لاحقاً.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|||||||
@@ -12,21 +12,19 @@ export function useArchivedUsers() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await archivedUserService.getAll(page, search, token);
|
const { items, total, pages } = await archivedUserService.getAllUnwrapped(page, search, token);
|
||||||
setUsers(res.data.data);
|
setUsers(items); setTotal(total); setPages(pages);
|
||||||
setTotal(res.data.meta.total);
|
setError(null);
|
||||||
setPages(res.data.meta.totalPages);
|
} catch {
|
||||||
setError(null);
|
setError("تعذّر تحميل قائمة الأرشيف. يرجى المحاولة لاحقاً.");
|
||||||
} catch {
|
} finally {
|
||||||
setError("تعذّر تحميل قائمة الأرشيف. يرجى المحاولة لاحقاً.");
|
setLoading(false);
|
||||||
} finally {
|
}
|
||||||
setLoading(false);
|
}, [page, search]);
|
||||||
}
|
|
||||||
}, [page, search]);
|
|
||||||
|
|
||||||
useEffect(() => { queueMicrotask(load); }, [load]);
|
useEffect(() => { queueMicrotask(load); }, [load]);
|
||||||
|
|
||||||
|
|||||||
@@ -46,21 +46,15 @@ export function useBranches() {
|
|||||||
|
|
||||||
// fetch branches with pagination and search
|
// fetch branches with pagination and search
|
||||||
const loadBranches = useCallback(async (p: number, q: string) => {
|
const loadBranches = useCallback(async (p: number, q: string) => {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await branchService.getAll(p, q, token);
|
const { items, total, pages } = await branchService.getAll(p, q, token);
|
||||||
const payload = res.data ?? res;
|
dispatch({ type: "LOAD_OK", branches: items, total, pages });
|
||||||
dispatch({
|
} catch {
|
||||||
type: "LOAD_OK",
|
dispatch({ type: "LOAD_ERR", error: "عذراً، حدث خطأ أثناء تحميل بيانات الفروع. يرجى المحاولة لاحقاً." });
|
||||||
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: "عذراً، حدث خطأ أثناء تحميل بيانات الفروع. يرجى المحاولة لاحقاً." });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// reload branches when page or search changes
|
// reload branches when page or search changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,27 +63,24 @@ export function useBranches() {
|
|||||||
|
|
||||||
// create new branch
|
// create new branch
|
||||||
const createBranch = useCallback(async (data: BranchFormData): Promise<boolean> => {
|
const createBranch = useCallback(async (data: BranchFormData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await branchService.create(data, token);
|
const branch = await branchService.create(data, token);
|
||||||
dispatch({ type: "ADD", branch: res.data });
|
dispatch({ type: "ADD", branch });
|
||||||
notify({ type: "success", message: "تم إضافة الفرع بنجاح." });
|
notify({ type: "success", message: "تم إضافة الفرع بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error && err.message
|
notify({ type: "error", message: err instanceof Error ? err.message : "تعذر الاتصال بالخادم." });
|
||||||
? err.message
|
return false;
|
||||||
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
|
}
|
||||||
notify({ type: "error", message: msg });
|
}, [notify]);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [notify]);
|
|
||||||
|
|
||||||
// update existing branch
|
// update existing branch
|
||||||
const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => {
|
const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await branchService.update(id, data, token);
|
const res = await branchService.update(id, data, token);
|
||||||
dispatch({ type: "UPDATE", branch: res.data });
|
dispatch({ type: "UPDATE", branch: res?.data });
|
||||||
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
|
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -19,23 +19,22 @@ import type {
|
|||||||
// Manages the paginated car list for the main page.
|
// Manages the paginated car list for the main page.
|
||||||
|
|
||||||
export function useCars(page: number, search: string) {
|
export function useCars(page: number, search: string) {
|
||||||
const [cars, setCars] = useState<Car[]>([]);
|
const [cars, setCars] = useState<Car[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
|
|
||||||
const loadCars = useCallback(() => {
|
const loadCars = useCallback(() => {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const query = `?page=${page}&limit=12${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
carService
|
||||||
get<CarListResponse>(`cars${query}`, token)
|
.getAll(page, search, token)
|
||||||
.then((res) => {
|
.then(({ items, total, pages }) => {
|
||||||
const payload = (res as unknown as { data: CarListResponse["data"] }).data ?? res;
|
setCars(items);
|
||||||
setCars((payload as CarListResponse["data"]).data ?? []);
|
setTotal(total);
|
||||||
setTotal((payload as CarListResponse["data"]).meta?.total ?? 0);
|
setPages(pages);
|
||||||
setPages((payload as CarListResponse["data"]).meta?.pages ?? 1);
|
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -43,10 +42,9 @@ export function useCars(page: number, search: string) {
|
|||||||
|
|
||||||
useEffect(() => { queueMicrotask(loadCars); }, [loadCars]);
|
useEffect(() => { queueMicrotask(loadCars); }, [loadCars]);
|
||||||
|
|
||||||
// Optimistic removal after delete
|
|
||||||
const removeCar = useCallback((id: string) => {
|
const removeCar = useCallback((id: string) => {
|
||||||
setCars(prev => prev.filter(c => c.id !== id));
|
setCars((prev) => prev.filter((c) => c.id !== id));
|
||||||
setTotal(prev => Math.max(0, prev - 1));
|
setTotal((prev) => Math.max(0, prev - 1));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { cars, loading, error, total, pages, loadCars, removeCar, setError };
|
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.
|
// record is created/updated/deleted elsewhere in the tree.
|
||||||
|
|
||||||
export function useCarDetail(carId: string) {
|
export function useCarDetail(carId: string) {
|
||||||
const [car, setCar] = useState<Car | null>(null);
|
const [car, setCar] = useState<Car | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadCar = useCallback(() => {
|
const loadCar = useCallback(() => {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
@@ -68,16 +66,16 @@ export function useCarDetail(carId: string) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
carService
|
carService
|
||||||
.getById(carId, token)
|
.getById(carId, token)
|
||||||
.then(res => setCar((res as unknown as { data: Car }).data))
|
.then(setCar)
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [carId]);
|
}, [carId]);
|
||||||
|
|
||||||
useEffect(() => { queueMicrotask(loadCar); }, [loadCar]);
|
useEffect(() => { queueMicrotask(loadCar); }, [loadCar]);
|
||||||
|
|
||||||
return { car, loading, error, refetch: loadCar };
|
return { car, loading, error, refetch: loadCar };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── useCarMutations ───────────────────────────────────────────────────────────
|
// ── useCarMutations ───────────────────────────────────────────────────────────
|
||||||
// Create, update, and delete operations — used in the main page.
|
// Create, update, and delete operations — used in the main page.
|
||||||
|
|
||||||
@@ -165,8 +163,8 @@ export function useCarImages(carId: string, sortBy: "asc" | "desc") {
|
|||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await carService.getImages(carId, token, { sortBy });
|
const res = await carService.getImages(carId, token, { sortBy });
|
||||||
const raw = (res as unknown as { data: CarImage[] }).data ?? [];
|
// The response shape: { data: CarImage[] }
|
||||||
setImages(raw);
|
setImages(res);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "تعذّر تحميل الصور");
|
setError(err instanceof Error ? err.message : "تعذّر تحميل الصور");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import type {
|
|||||||
} from "../types/client_adresses";
|
} from "../types/client_adresses";
|
||||||
import { Notification } from "../types/notif";
|
import { Notification } from "../types/notif";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function normalizeAddress(raw: unknown): ClientAddress {
|
function normalizeAddress(raw: unknown): ClientAddress {
|
||||||
const data = raw as { id?: string; _id?: string; [key: string]: unknown };
|
const data = raw as { id?: string; _id?: string; [key: string]: unknown };
|
||||||
|
|
||||||
@@ -34,7 +32,10 @@ function normalizeAddresses(rawList: unknown[]): ClientAddress[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Reducer ────────────────────────────────────────────────────────────────
|
// ── Reducer ────────────────────────────────────────────────────────────────
|
||||||
function reducer(s: AddressTableState, a: AddressTableAction): AddressTableState {
|
function reducer(
|
||||||
|
s: AddressTableState,
|
||||||
|
a: AddressTableAction,
|
||||||
|
): AddressTableState {
|
||||||
switch (a.type) {
|
switch (a.type) {
|
||||||
case "LOAD_START":
|
case "LOAD_START":
|
||||||
return { ...s, loading: true, error: null };
|
return { ...s, loading: true, error: null };
|
||||||
@@ -48,7 +49,7 @@ function reducer(s: AddressTableState, a: AddressTableAction): AddressTableState
|
|||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
addresses: s.addresses.map((a2) =>
|
addresses: s.addresses.map((a2) =>
|
||||||
a2.id === a.address.id ? a.address : a2
|
a2.id === a.address.id ? a.address : a2,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
@@ -91,33 +92,23 @@ export function useClientAddresses(clientId: string) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Fetch ────────────────────────────────────────────────────────────────
|
// ── Fetch ────────────────────────────────────────────────────────────────
|
||||||
const loadAddresses = useCallback(async () => {
|
const loadAddresses = useCallback(async () => {
|
||||||
if (!clientId) return;
|
if (!clientId) return;
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await clientAddressService.getAll(clientId, token);
|
const addresses = await clientAddressService.getAllNormalized(
|
||||||
const payload = (res as unknown as { data?: unknown }).data ?? res;
|
clientId,
|
||||||
|
token,
|
||||||
// FIXED: explicit Array.isArray checks instead of relying on
|
);
|
||||||
// TS to narrow the ternary's return type on its own.
|
dispatch({ type: "LOAD_OK", addresses });
|
||||||
const rawList: unknown[] = Array.isArray(payload)
|
} catch {
|
||||||
? payload
|
dispatch({
|
||||||
: Array.isArray((payload as { data?: unknown })?.data)
|
type: "LOAD_ERR",
|
||||||
? ((payload as { data?: unknown }).data as unknown[])
|
error: "تعذّر تحميل العناوين. يرجى المحاولة مجدداً.",
|
||||||
: [];
|
});
|
||||||
|
}
|
||||||
dispatch({
|
}, [clientId]);
|
||||||
type: "LOAD_OK",
|
|
||||||
addresses: normalizeAddresses(rawList),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
dispatch({
|
|
||||||
type: "LOAD_ERR",
|
|
||||||
error: "تعذّر تحميل العناوين. يرجى المحاولة مجدداً.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [clientId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAddresses();
|
loadAddresses();
|
||||||
@@ -129,7 +120,7 @@ export function useClientAddresses(clientId: string) {
|
|||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await clientAddressService.create(clientId, data, token);
|
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: "تم إضافة العنوان بنجاح." });
|
notify({ type: "success", message: "تم إضافة العنوان بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -148,7 +139,12 @@ export function useClientAddresses(clientId: string) {
|
|||||||
async (id: string, data: UpdateAddressFormValues): Promise<boolean> => {
|
async (id: string, data: UpdateAddressFormValues): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
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) });
|
dispatch({ type: "UPDATE", address: normalizeAddress(res.data) });
|
||||||
notify({ type: "success", message: "تم تحديث العنوان." });
|
notify({ type: "success", message: "تم تحديث العنوان." });
|
||||||
return true;
|
return true;
|
||||||
@@ -200,7 +196,8 @@ export function useClientAddresses(clientId: string) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify({
|
notify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: err instanceof Error ? err.message : "تعذّر تعيين العنوان كأساسي.",
|
message:
|
||||||
|
err instanceof Error ? err.message : "تعذّر تعيين العنوان كأساسي.",
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -71,33 +71,16 @@ export function useClients() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Fetch clients ────────────────────────────────────────────────────────
|
// ── Fetch clients ────────────────────────────────────────────────────────
|
||||||
const loadClients = useCallback(async (p: number, q: string) => {
|
const loadClients = useCallback(async (p: number, q: string) => {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await clientService.getAll(p, q, token);
|
const { items, total, pages } = await clientService.getAll(p, q, token);
|
||||||
|
dispatch({ type: "LOAD_OK", clients: items, total, pages });
|
||||||
// Normalise both pagination shapes the backend might return
|
} catch {
|
||||||
const apiResponse = res as { data?: unknown };
|
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات العملاء. يرجى المحاولة مجدداً." });
|
||||||
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: "تعذّر تحميل بيانات العملاء. يرجى المحاولة مجدداً.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reload whenever page or search changes
|
// Reload whenever page or search changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,24 +94,18 @@ export function useClients() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── CREATE ───────────────────────────────────────────────────────────────
|
// ── CREATE ───────────────────────────────────────────────────────────────
|
||||||
const createClient = useCallback(
|
const createClient = useCallback(async (data: ClientFormData): Promise<boolean> => {
|
||||||
async (data: ClientFormData): Promise<boolean> => {
|
try {
|
||||||
try {
|
const token = getStoredToken();
|
||||||
const token = getStoredToken();
|
const client = await clientService.create(data, token);
|
||||||
const res = await clientService.create(data, token);
|
dispatch({ type: "ADD", client });
|
||||||
dispatch({ type: "ADD", client: res.data });
|
notify({ type: "success", message: "تم إضافة العميل بنجاح." });
|
||||||
notify({ type: "success", message: "تم إضافة العميل بنجاح." });
|
return true;
|
||||||
return true;
|
} catch (err) {
|
||||||
} catch (err) {
|
notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر إضافة العميل." });
|
||||||
notify({
|
return false;
|
||||||
type: "error",
|
}
|
||||||
message: err instanceof Error ? err.message : "تعذّر إضافة العميل.",
|
}, [notify]);
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[notify],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── UPDATE ───────────────────────────────────────────────────────────────
|
// ── UPDATE ───────────────────────────────────────────────────────────────
|
||||||
const updateClient = useCallback(
|
const updateClient = useCallback(
|
||||||
@@ -136,7 +113,7 @@ export function useClients() {
|
|||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await clientService.update(id, data, token);
|
const res = await clientService.update(id, data, token);
|
||||||
dispatch({ type: "UPDATE", client: res.data });
|
dispatch({ type: "UPDATE", client: res?.data });
|
||||||
notify({ type: "success", message: "تم تحديث بيانات العميل." });
|
notify({ type: "success", message: "تم تحديث بيانات العميل." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ function reducer(s: TableState, a: TableAction): TableState {
|
|||||||
return { ...s, loading: false, error: a.error };
|
return { ...s, loading: false, error: a.error };
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return { ...s, drivers: s.drivers.filter((d) => d.id !== a.id) };
|
return { ...s, drivers: s.drivers.filter((d) => d.id !== a.id) };
|
||||||
// Instantly reflect updated driver in the list without a full reload
|
|
||||||
case "UPDATE":
|
case "UPDATE":
|
||||||
return { ...s, drivers: s.drivers.map((d) => (d.id === a.driver.id ? a.driver : d)) };
|
return { ...s, drivers: s.drivers.map((d) => (d.id === a.driver.id ? a.driver : d)) };
|
||||||
case "CLEAR_ERR":
|
case "CLEAR_ERR":
|
||||||
@@ -60,7 +59,6 @@ export function useDrivers() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||||
|
|
||||||
// Show a toast for 4 seconds then auto-dismiss
|
|
||||||
const notify = useCallback((n: ToastNotification) => {
|
const notify = useCallback((n: ToastNotification) => {
|
||||||
setNotification(n);
|
setNotification(n);
|
||||||
setTimeout(() => setNotification(null), 4000);
|
setTimeout(() => setNotification(null), 4000);
|
||||||
@@ -71,23 +69,8 @@ export function useDrivers() {
|
|||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await driverService.getAll(p, q, token);
|
const { items, total, pages } = await driverService.getAll(p, q, token);
|
||||||
const payload = (
|
dispatch({ type: "LOAD_OK", drivers: items, total, pages });
|
||||||
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,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات السائقين. يرجى المحاولة مجدداً." });
|
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات السائقين. يرجى المحاولة مجدداً." });
|
||||||
}
|
}
|
||||||
@@ -111,8 +94,7 @@ export function useDrivers() {
|
|||||||
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
|
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
|
||||||
|
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
const res = await driverService.createWithImages(payload, token);
|
const created = await driverService.createWithImages(payload, token);
|
||||||
const created = (res as unknown as { data: Driver }).data;
|
|
||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
await driverService.getById(created.id, token).catch(() => null);
|
await driverService.getById(created.id, token).catch(() => null);
|
||||||
}
|
}
|
||||||
@@ -124,10 +106,9 @@ export function useDrivers() {
|
|||||||
await loadDrivers(page, search);
|
await loadDrivers(page, search);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ✅ بنعرض رسالة الخطأ كـ toast برضو، مش بس نرميها لفوق
|
|
||||||
const message = err instanceof Error ? err.message : "تعذّر إضافة السائق. يرجى المحاولة لاحقاً.";
|
const message = err instanceof Error ? err.message : "تعذّر إضافة السائق. يرجى المحاولة لاحقاً.";
|
||||||
notify({ type: "error", message });
|
notify({ type: "error", message });
|
||||||
throw err; // يفضل يترمي عشان DriverFormModal يعرضه جوه المودال كمان لو محتاج
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notify, loadDrivers, page, search],
|
[notify, loadDrivers, page, search],
|
||||||
@@ -147,22 +128,14 @@ export function useDrivers() {
|
|||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
|
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
|
||||||
|
|
||||||
let updatedDriver: Driver;
|
const updatedDriver = hasFiles
|
||||||
|
? await driverService.updateWithImages(id, payload, token)
|
||||||
if (hasFiles) {
|
: await driverService.update(id, payload, token);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({ type: "UPDATE", driver: updatedDriver });
|
dispatch({ type: "UPDATE", driver: updatedDriver });
|
||||||
notify({ type: "success", message: "تم تحديث بيانات السائق بنجاح." });
|
notify({ type: "success", message: "تم تحديث بيانات السائق بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ✅ نفس الفكرة هنا — أي فشل في التحديث يبان كـ toast أحمر
|
|
||||||
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق. يرجى المحاولة لاحقاً.";
|
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق. يرجى المحاولة لاحقاً.";
|
||||||
notify({ type: "error", message });
|
notify({ type: "error", message });
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import type { ToastNotification } from "@/src/Components/UI";
|
|||||||
export type OrderNotification = ToastNotification;
|
export type OrderNotification = ToastNotification;
|
||||||
|
|
||||||
// ── Table state / reducer ──────────────────────────────────────────────────
|
// ── Table state / reducer ──────────────────────────────────────────────────
|
||||||
// Mirrors useDriver.ts's TableState/TableAction shape so the two resource
|
|
||||||
// hooks stay readable side by side.
|
|
||||||
interface TableState {
|
interface TableState {
|
||||||
orders: Order[];
|
orders: Order[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -41,7 +39,6 @@ function reducer(s: TableState, a: TableAction): TableState {
|
|||||||
return { ...s, loading: false, error: a.error };
|
return { ...s, loading: false, error: a.error };
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return { ...s, orders: s.orders.filter((o) => o.id !== a.id) };
|
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":
|
case "UPDATE":
|
||||||
return { ...s, orders: s.orders.map((o) => (o.id === a.order.id ? a.order : o)) };
|
return { ...s, orders: s.orders.map((o) => (o.id === a.order.id ? a.order : o)) };
|
||||||
case "CLEAR_ERR":
|
case "CLEAR_ERR":
|
||||||
@@ -67,7 +64,6 @@ export function useOrders() {
|
|||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
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) => {
|
const notify = useCallback((n: ToastNotification) => {
|
||||||
setNotification(n);
|
setNotification(n);
|
||||||
setTimeout(() => setNotification(null), 4000);
|
setTimeout(() => setNotification(null), 4000);
|
||||||
@@ -77,20 +73,14 @@ export function useOrders() {
|
|||||||
const loadOrders = useCallback(async (p: number, q: string, status: string) => {
|
const loadOrders = useCallback(async (p: number, q: string, status: string) => {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const res = await orderService.getAll({
|
const { items, total, pages } = await orderService.getAll({
|
||||||
page: p,
|
page: p,
|
||||||
limit: 12,
|
limit: 12,
|
||||||
...(q ? { search: q } : {}),
|
...(q ? { search: q } : {}),
|
||||||
...(status ? { currentStatus: status } : {}),
|
...(status ? { currentStatus: status } : {}),
|
||||||
});
|
});
|
||||||
const payload = (res as unknown as { data: { data: Order[]; meta: { total: number; totalPages: number } } }).data ?? res;
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({ type: "LOAD_OK", orders: items, total, pages });
|
||||||
type: "LOAD_OK",
|
|
||||||
orders: payload.data ?? [],
|
|
||||||
total: payload.meta?.total ?? 0,
|
|
||||||
pages: payload.meta?.totalPages ?? 1,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات الطلبات. يرجى المحاولة مجدداً." });
|
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات الطلبات. يرجى المحاولة مجدداً." });
|
||||||
}
|
}
|
||||||
@@ -109,10 +99,9 @@ export function useOrders() {
|
|||||||
await loadOrders(page, search, statusFilter);
|
await loadOrders(page, search, statusFilter);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// نعرض رسالة الخطأ كـ toast برضو، مش بس نرميها لفوق
|
|
||||||
const message = err instanceof Error ? err.message : "تعذّر إنشاء الطلب. يرجى المحاولة لاحقاً.";
|
const message = err instanceof Error ? err.message : "تعذّر إنشاء الطلب. يرجى المحاولة لاحقاً.";
|
||||||
notify({ type: "error", message });
|
notify({ type: "error", message });
|
||||||
throw err; // يفضل يترمي عشان OrderFormModal يعرضه جوه المودال كمان لو محتاج
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notify, loadOrders, page, search, statusFilter],
|
[notify, loadOrders, page, search, statusFilter],
|
||||||
@@ -122,8 +111,7 @@ export function useOrders() {
|
|||||||
const updateOrder = useCallback(
|
const updateOrder = useCallback(
|
||||||
async (id: string, payload: UpdateOrderPayload): Promise<boolean> => {
|
async (id: string, payload: UpdateOrderPayload): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await orderService.update(id, payload);
|
const updated = await orderService.update(id, payload);
|
||||||
const updated = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
|
||||||
dispatch({ type: "UPDATE", order: updated });
|
dispatch({ type: "UPDATE", order: updated });
|
||||||
notify({ type: "success", message: "تم تحديث الطلب بنجاح." });
|
notify({ type: "success", message: "تم تحديث الطلب بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
@@ -140,8 +128,7 @@ export function useOrders() {
|
|||||||
const updateStatus = useCallback(
|
const updateStatus = useCallback(
|
||||||
async (id: string, payload: UpdateOrderStatusPayload): Promise<boolean> => {
|
async (id: string, payload: UpdateOrderStatusPayload): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await orderService.updateStatus(id, payload);
|
const updated = await orderService.updateStatus(id, payload);
|
||||||
const updated = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
|
||||||
dispatch({ type: "UPDATE", order: updated });
|
dispatch({ type: "UPDATE", order: updated });
|
||||||
notify({ type: "success", message: "تم تحديث حالة الطلب بنجاح." });
|
notify({ type: "success", message: "تم تحديث حالة الطلب بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -82,33 +82,20 @@ export function useRoles() {
|
|||||||
|
|
||||||
// ── Fetch roles ─────────────────────────────────────────────────────────────
|
// ── Fetch roles ─────────────────────────────────────────────────────────────
|
||||||
const loadRoles = useCallback(async (p: number, q: string) => {
|
const loadRoles = useCallback(async (p: number, q: string) => {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await roleService.getAll(p, q, token);
|
const { items, total, pages } = await roleService.getAll(p, q, token);
|
||||||
const payload =
|
dispatch({ type: "LOAD_OK", roles: items, total, pages });
|
||||||
(
|
} catch {
|
||||||
res as unknown as {
|
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات الأدوار. يرجى المحاولة مجدداً." });
|
||||||
data: {
|
}
|
||||||
data: Role[];
|
}, []);
|
||||||
meta?: { total: number; pages: number };
|
|
||||||
pagination?: { total: number; pages: number };
|
useEffect(() => {
|
||||||
};
|
const token = getStoredToken();
|
||||||
}
|
roleService.getPermissions(token).then(setPermissions).catch(() => {});
|
||||||
).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: "تعذّر تحميل بيانات الأدوار. يرجى المحاولة مجدداً.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Load permissions on mount ────────────────────────────────────────────────
|
// ── Load permissions on mount ────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,36 +118,24 @@ export function useRoles() {
|
|||||||
|
|
||||||
// ── CRUD actions ─────────────────────────────────────────────────────────────
|
// ── CRUD actions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const createRole = useCallback(
|
const createRole = useCallback(
|
||||||
async (data: RoleFormData): Promise<boolean> => {
|
async (data: RoleFormData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await roleService.create(data, token);
|
const role = await roleService.create(data, token);
|
||||||
const role = (res as unknown as { data: Role }).data;
|
if (data.permissionIds.length) {
|
||||||
|
await roleService.bulkAssignPermissions(role.id, data.permissionIds, token);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
},
|
await loadRoles(page, search);
|
||||||
[page, search, loadRoles, notify],
|
notify({ type: "success", message: "تم إنشاء الدور وتعيين الصلاحيات بنجاح." });
|
||||||
);
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify({ type: "error", message: parseApiError(err, "تعذّر إنشاء الدور.") });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[page, search, loadRoles, notify],
|
||||||
|
);
|
||||||
|
|
||||||
const updateRole = useCallback(
|
const updateRole = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|||||||
@@ -102,36 +102,22 @@ export function useTrips() {
|
|||||||
|
|
||||||
// ── Fetch trips ───────────────────────────────────────────────────────────
|
// ── Fetch trips ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const loadTrips = useCallback(
|
const loadTrips = useCallback(
|
||||||
async (p: number, q: string, st: TripStatus | "") => {
|
async (p: number, q: string, st: TripStatus | "") => {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await tripService.getAll(
|
const { items, total, pages } = await tripService.getAll(
|
||||||
{ page: p, limit: 12, search: q || undefined, status: st || undefined },
|
{ page: p, limit: 12, search: q || undefined, status: st || undefined },
|
||||||
token,
|
token,
|
||||||
);
|
);
|
||||||
const payload = (
|
dispatch({ type: "LOAD_OK", trips: items, total, pages });
|
||||||
res as unknown as {
|
} catch (err) {
|
||||||
data: { data: Trip[]; pagination?: { total: number; totalPages: number } };
|
dispatch({ type: "LOAD_ERR", error: extractApiMessage(err, "تعذّر تحميل بيانات الرحلات. يرجى المحاولة مجدداً.") });
|
||||||
}
|
}
|
||||||
).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, "تعذّر تحميل بيانات الرحلات. يرجى المحاولة مجدداً."),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTrips(page, search, status);
|
loadTrips(page, search, status);
|
||||||
@@ -139,42 +125,38 @@ export function useTrips() {
|
|||||||
|
|
||||||
// ── Create ────────────────────────────────────────────────────────────────
|
// ── Create ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const createTrip = useCallback(
|
const createTrip = useCallback(
|
||||||
async (payload: CreateTripPayload): Promise<boolean> => {
|
async (payload: CreateTripPayload): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await tripService.create(payload, token);
|
const newTrip = await tripService.create(payload, token);
|
||||||
const newTrip = (res as unknown as { data: Trip }).data;
|
dispatch({ type: "ADD", trip: newTrip });
|
||||||
dispatch({ type: "ADD", trip: newTrip });
|
notify({ type: "success", message: "تم إضافة الرحلة بنجاح." });
|
||||||
notify({ type: "success", message: "تم إضافة الرحلة بنجاح." });
|
return true;
|
||||||
return true;
|
} catch (err) {
|
||||||
} catch (err) {
|
notify({ type: "error", message: extractApiMessage(err, "تعذّر إضافة الرحلة.") });
|
||||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر إضافة الرحلة.") });
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
},
|
||||||
},
|
[notify],
|
||||||
[notify],
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// ── Update ────────────────────────────────────────────────────────────────
|
// ── Update ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const updateTrip = useCallback(
|
const updateTrip = useCallback(
|
||||||
async (id: string, payload: UpdateTripPayload): Promise<boolean> => {
|
async (id: string, payload: UpdateTripPayload): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await tripService.update(id, payload, token);
|
const updated = await tripService.update(id, payload, token);
|
||||||
const updated = (res as unknown as { data: Trip }).data;
|
dispatch({ type: "UPDATE", trip: updated });
|
||||||
dispatch({ type: "UPDATE", trip: updated });
|
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||||||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
return true;
|
||||||
return true;
|
} catch (err) {
|
||||||
} catch (err) {
|
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
||||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
},
|
||||||
},
|
[notify],
|
||||||
[notify],
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// ── Delete ────────────────────────────────────────────────────────────────
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const deleteTrip = useCallback(
|
const deleteTrip = useCallback(
|
||||||
|
|||||||
@@ -49,33 +49,22 @@ export function useUsers() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// fetch users with pagination and search
|
// fetch users with pagination and search
|
||||||
const loadUsers = useCallback(async (p: number, q: string) => {
|
const loadUsers = useCallback(async (p: number, q: string) => {
|
||||||
dispatch({ type: "LOAD_START" });
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
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 token = getStoredToken();
|
const token = getStoredToken();
|
||||||
userService.getRoles(token)
|
const { items, total, pages } = await userService.getAll(p, q, token);
|
||||||
.then(res => setRoles((res.data ?? res).data ?? []))
|
dispatch({ type: "LOAD_OK", users: items, total, pages });
|
||||||
.catch(() => {});
|
} catch {
|
||||||
userService.getBranches(token)
|
dispatch({ type: "LOAD_ERR", error: "عذراً، حدث خطأ أثناء تحميل بيانات المستخدمين. يرجى المحاولة لاحقاً." });
|
||||||
.then(res => setBranches((res.data ?? res).data ?? []))
|
}
|
||||||
.catch(() => {});
|
}, []);
|
||||||
}, []);
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getStoredToken();
|
||||||
|
userService.getRoles(token).then(setRoles).catch(() => {});
|
||||||
|
userService.getBranches(token).then(setBranches).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// reload users when page or search changes
|
// reload users when page or search changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -83,28 +72,25 @@ export function useUsers() {
|
|||||||
}, [page, search, loadUsers]);
|
}, [page, search, loadUsers]);
|
||||||
|
|
||||||
// create new user
|
// create new user
|
||||||
const createUser = useCallback(async (data: UserFormData): Promise<boolean> => {
|
const createUser = useCallback(async (data: UserFormData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await userService.create(data as UserFormData & { password: string }, token);
|
const user = await userService.create(data as UserFormData & { password: string }, token);
|
||||||
dispatch({ type: "ADD", user: res.data });
|
dispatch({ type: "ADD", user });
|
||||||
notify({ type: "success", message: "تم إنشاء مستخدم جديد بنجاح." });
|
notify({ type: "success", message: "تم إنشاء مستخدم جديد بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error && err.message
|
notify({ type: "error", message: err instanceof Error && err.message ? err.message : "تعذر الاتصال بالخادم." });
|
||||||
? err.message
|
return false;
|
||||||
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
|
}
|
||||||
notify({ type: "error", message: msg });
|
}, [notify]);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [notify]);
|
|
||||||
|
|
||||||
// update existing user
|
// update existing user
|
||||||
const updateUser = useCallback(async (id: string, data: UserFormData): Promise<boolean> => {
|
const updateUser = useCallback(async (id: string, data: UserFormData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await userService.update(id, data, token);
|
const res = await userService.update(id, data, token);
|
||||||
dispatch({ type: "UPDATE", user: res.data });
|
dispatch({ type: "UPDATE", user: res?.data });
|
||||||
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
|
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -16,4 +16,8 @@ export const archivedBranchService = {
|
|||||||
`branches/archived${buildArchivedQuery(page, search)}`,
|
`branches/archived${buildArchivedQuery(page, search)}`,
|
||||||
token,
|
token,
|
||||||
),
|
),
|
||||||
|
getAllUnwrapped: async (page, search, token) => {
|
||||||
|
const res = await archivedBranchService.getAll(page, search, token);
|
||||||
|
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -28,7 +28,15 @@ export const archivedClientService = {
|
|||||||
`client/${clientId}/orders/archived?page=${page}&limit=10`,
|
`client/${clientId}/orders/archived?page=${page}&limit=10`,
|
||||||
token,
|
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
|
// NOTE: delete/restore intentionally left out for now — mirrors
|
||||||
// archivedUser.service.ts, see ticket follow-up.
|
// archivedUser.service.ts, see ticket follow-up.
|
||||||
};
|
};
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
import { get } from "../api";
|
import { get } from "../api";
|
||||||
import type { ArchivedClientAddressListResponse } from "@/src/types/client_adresses";
|
import type { ArchivedClientAddress, ArchivedClientAddressListResponse } from "@/src/types/client_adresses";
|
||||||
|
|
||||||
export const archivedClientAddressService = {
|
export const archivedClientAddressService = {
|
||||||
/**
|
getAll: (token: string | null) => get<ArchivedClientAddressListResponse>("addresses/archived", token),
|
||||||
* Fetching every archived (soft-deleted) address across all clients.
|
getAllUnwrapped: async (token: string | null): Promise<ArchivedClientAddress[]> =>
|
||||||
* NOTE: this endpoint returns a flat array with no pagination meta,
|
(await archivedClientAddressService.getAll(token)).data,
|
||||||
* 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.
|
|
||||||
};
|
};
|
||||||
@@ -43,4 +43,10 @@ export const archivedDriverService = {
|
|||||||
*/
|
*/
|
||||||
getStatusHistory: (id: string, token: string | null) =>
|
getStatusHistory: (id: string, token: string | null) =>
|
||||||
get<ArchivedDriverStatusHistoryResponse>(`driver/archived/driverStatus/${id}`, token),
|
get<ArchivedDriverStatusHistoryResponse>(`driver/archived/driverStatus/${id}`, token),
|
||||||
|
getAllUnwrapped: async (page, search, token) => {
|
||||||
|
const res = await archivedDriverService.getAll(page, search, token);
|
||||||
|
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||||
|
},
|
||||||
|
getByIdUnwrapped: async (id, token) => (await archivedDriverService.getById(id, token)).data,
|
||||||
|
getStatusHistoryUnwrapped: async (id, token) => (await archivedDriverService.getStatusHistory(id, token)).data ?? [],
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { get } from "../api";
|
import { get } from "../api";
|
||||||
import type { ArchivedOrderListResponse } from "@/src/types/order";
|
import type { ArchivedOrder, ArchivedOrderListResponse } from "@/src/types/order";
|
||||||
|
|
||||||
export const archivedOrderService = {
|
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) =>
|
getAllUnwrapped: async (token: string | null): Promise<ArchivedOrder[]> => {
|
||||||
get<ArchivedOrderListResponse>("orders/archived", token),
|
const res = await archivedOrderService.getAll(token);
|
||||||
|
return Array.isArray(res.data?.data) ? res.data.data : [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
import { get } from "../api";
|
import { get } from "../api";
|
||||||
import type {
|
import type {
|
||||||
|
ArchivedRole,
|
||||||
ArchivedRoleListResponse,
|
ArchivedRoleListResponse,
|
||||||
ArchivedRoleResponse,
|
ArchivedRoleResponse,
|
||||||
} from "@/src/types/role";
|
} from "@/src/types/role";
|
||||||
|
|
||||||
export const archivedRoleService = {
|
export const archivedRoleService = {
|
||||||
/** Fetching the full archived role list — API returns a plain array,
|
getAll: (token: string | null) => get<ArchivedRoleListResponse>("role/archived", token),
|
||||||
* no server-side pagination/search, unlike users/branches archives. */
|
|
||||||
getAll: (token: string | null) =>
|
getAllUnwrapped: async (token: string | null): Promise<ArchivedRole[]> => {
|
||||||
get<ArchivedRoleListResponse>("role/archived", token),
|
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) =>
|
getById: (id: string, token: string | null) =>
|
||||||
get<ArchivedRoleResponse>(`role/archived/${id}`, token),
|
get<ArchivedRoleResponse>(`role/archived/${id}`, token),
|
||||||
|
|
||||||
// NOTE: delete/restore intentionally left out for now — mirrors the
|
getByIdUnwrapped: async (id: string, token: string | null): Promise<ArchivedRole> =>
|
||||||
// archivedUser.service.ts / archivedBranch.service.ts follow-up ticket.
|
(await archivedRoleService.getById(id, token)).data,
|
||||||
};
|
};
|
||||||
@@ -17,4 +17,9 @@ export const archivedTripService = {
|
|||||||
/** Get a single archived trip by id */
|
/** Get a single archived trip by id */
|
||||||
getById: (id: string, token: string | null) =>
|
getById: (id: string, token: string | null) =>
|
||||||
get<ArchivedTripResponse>(`trip/archived/${id}`, token),
|
get<ArchivedTripResponse>(`trip/archived/${id}`, token),
|
||||||
|
getAllUnwrapped: async (page, search, token) => {
|
||||||
|
const res = await archivedTripService.getAll(page, search, token);
|
||||||
|
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||||
|
},
|
||||||
|
getByIdUnwrapped: async (id, token) => (await archivedTripService.getById(id, token)).data,
|
||||||
};
|
};
|
||||||
@@ -10,16 +10,16 @@ function buildArchivedQuery(page: number, search: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const archivedUserService = {
|
export const archivedUserService = {
|
||||||
/** Fetching the archived user list, paginated */
|
|
||||||
getAll: (page: number, search: string, token: string | null) =>
|
getAll: (page: number, search: string, token: string | null) =>
|
||||||
get<ArchivedUserListResponse>(
|
get<ArchivedUserListResponse>(`users/archived${buildArchivedQuery(page, search)}`, token),
|
||||||
`users/archived${buildArchivedQuery(page, search)}`,
|
|
||||||
token,
|
|
||||||
),
|
|
||||||
|
|
||||||
/** Get a single archived user by id */
|
getAllUnwrapped: async (page: number, search: string, token: string | null) => {
|
||||||
getById: (id: string, token: string | null) =>
|
const res = await archivedUserService.getAll(page, search, token);
|
||||||
get<ArchivedUserResponse>(`users/archived/${id}`, token),
|
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||||
|
},
|
||||||
|
|
||||||
// NOTE: delete/restore intentionally left out for now — see ticket follow-up.
|
getById: async (id: string, token: string | null) => {
|
||||||
|
const res = await get<ArchivedUserResponse>(`users/archived/${id}`, token);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,56 +1,64 @@
|
|||||||
|
|
||||||
import { get, post, patch, del } from "./api";
|
import { 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 {
|
function buildBranchesQuery(page: number, search: string): string {
|
||||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const branchService = {
|
export const branchService = {
|
||||||
/** Bring up the list of branches with the ability to search and browse between pages*/
|
getAll: async (page: number, search: string, token: string | null): Promise<BranchListResult> => {
|
||||||
getAll: (page: number, search: string, token: string | null) =>
|
const res: ApiListResponse<Branch> = await get<ApiListResponse<Branch>>(
|
||||||
get<ApiListResponse<Branch>>(`branches${buildBranchesQuery(page, search)}`, token),
|
`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: async (data: BranchFormData, token: string | null): Promise<Branch> => {
|
||||||
create: (data: BranchFormData, token: string | null) =>
|
const res: BranchResponse = await post<BranchResponse>("branches", buildPayload(data), token);
|
||||||
post<BranchResponse>("branches", buildPayload(data), token),
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/** update branch*/
|
update: async (id: string, data: Partial<BranchFormData>, token: string | null): Promise<Branch> => {
|
||||||
update: (id: string, data: Partial<BranchFormData>, token: string | null) =>
|
const res: BranchResponse = await patch<BranchResponse>(`branches/${id}`, buildPayload(data), token);
|
||||||
patch<BranchResponse>(`branches/${id}`, buildPayload(data), token),
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**delete branch*/
|
|
||||||
delete: (id: string, token: string | null) => del<void>(`branches/${id}`, token),
|
delete: (id: string, token: string | null) => del<void>(`branches/${id}`, token),
|
||||||
|
|
||||||
/** get branch by id*/
|
getById: async (id: string, token: string | null): Promise<BranchDetail> => {
|
||||||
getById: (id: string, token: string | null) =>
|
const res = await get<{ data: BranchDetail }>(`branches/${id}`, token);
|
||||||
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> = {};
|
const payload: Record<string, string | number> = {};
|
||||||
|
|
||||||
if (data.name !== undefined) payload.name = data.name.trim();
|
if (data.name !== undefined) payload.name = data.name.trim();
|
||||||
if (data.email) payload.email = data.email.trim();
|
if (data.email) payload.email = data.email.trim();
|
||||||
if (data.phone) payload.phone = data.phone.trim();
|
if (data.phone) payload.phone = data.phone.trim();
|
||||||
if (data.country) payload.country = data.country.trim();
|
if (data.country) payload.country = data.country.trim();
|
||||||
if (data.city !== undefined) payload.city = data.city.trim();
|
if (data.city !== undefined) payload.city = data.city.trim();
|
||||||
if (data.state) payload.state = data.state.trim();
|
if (data.state) payload.state = data.state.trim();
|
||||||
if (data.district) payload.district = data.district.trim();
|
if (data.district) payload.district = data.district.trim();
|
||||||
if (data.street !== undefined) payload.street = data.street.trim();
|
if (data.street !== undefined) payload.street = data.street.trim();
|
||||||
if (data.buildingNo) payload.buildingNo = data.buildingNo.trim();
|
if (data.buildingNo) payload.buildingNo = data.buildingNo.trim();
|
||||||
if (data.unitNo) payload.unitNo = data.unitNo.trim();
|
if (data.unitNo) payload.unitNo = data.unitNo.trim();
|
||||||
if (data.zipCode) payload.zipCode = data.zipCode.trim();
|
if (data.zipCode) payload.zipCode = data.zipCode.trim();
|
||||||
|
if (data.latitude && !isNaN(Number(data.latitude))) payload.latitude = Number(data.latitude);
|
||||||
if (data.latitude && !isNaN(Number(data.latitude))) payload.latitude = Number(data.latitude);
|
|
||||||
if (data.longitude && !isNaN(Number(data.longitude))) payload.longitude = Number(data.longitude);
|
if (data.longitude && !isNaN(Number(data.longitude))) payload.longitude = Number(data.longitude);
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import { get, post, patch, del } from "./api";
|
import { get, post, patch, del } from "./api";
|
||||||
import type {
|
import type {
|
||||||
Car,
|
Car, CarImage, CarListResponse, CarDetailResponse, CarImageListResponse,
|
||||||
CarListResponse,
|
CarListResult, CarOption, CreateCarPayload, UpdateCarPayload,
|
||||||
CarDetailResponse,
|
|
||||||
CarImageListResponse,
|
|
||||||
CreateCarPayload,
|
|
||||||
UpdateCarPayload,
|
|
||||||
} from "@/src/types/car";
|
} from "@/src/types/car";
|
||||||
|
|
||||||
/** Build a query string for list endpoints */
|
|
||||||
function buildQuery(params: Record<string, string | number | undefined>): string {
|
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
||||||
if (!entries.length) return "";
|
if (!entries.length) return "";
|
||||||
@@ -16,91 +11,64 @@ function buildQuery(params: Record<string, string | number | undefined>): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const carService = {
|
export const carService = {
|
||||||
/**
|
getAll: async (page = 1, search = "", token: string | null): Promise<CarListResult> => {
|
||||||
* GET /cars
|
const res: CarListResponse = await get<CarListResponse>(
|
||||||
* Fetch paginated + searchable car list.
|
|
||||||
*/
|
|
||||||
getAll: (
|
|
||||||
page = 1,
|
|
||||||
search = "",
|
|
||||||
token: string | null,
|
|
||||||
) =>
|
|
||||||
get<CarListResponse>(
|
|
||||||
`cars${buildQuery({ page, limit: 10, search: search || undefined })}`,
|
`cars${buildQuery({ page, limit: 10, search: search || undefined })}`,
|
||||||
token,
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
getArchived: async (token: string | null): Promise<Car[]> => {
|
||||||
* GET /cars/archived
|
const res: CarListResponse = await get<CarListResponse>("cars/archived", token);
|
||||||
* Fetch soft-deleted cars.
|
return res.data.data;
|
||||||
*/
|
},
|
||||||
getArchived: (token: string | null) =>
|
|
||||||
get<CarListResponse>("cars/archived", token),
|
|
||||||
|
|
||||||
/**
|
getById: async (id: string, token: string | null): Promise<Car> => {
|
||||||
* GET /cars/:id
|
const res: CarDetailResponse = await get<CarDetailResponse>(`cars/${id}`, token);
|
||||||
* Fetch a single car with status history.
|
return res.data;
|
||||||
*/
|
},
|
||||||
getById: (id: string, token: string | null) =>
|
|
||||||
get<CarDetailResponse>(`cars/${id}`, token),
|
|
||||||
|
|
||||||
/**
|
create: async (payload: CreateCarPayload, token: string | null): Promise<Car> => {
|
||||||
* POST /cars
|
const res = await post<{ data: Car }>("cars", payload, token);
|
||||||
* Create a new car record.
|
return res.data;
|
||||||
*/
|
},
|
||||||
create: (payload: CreateCarPayload, token: string | null) =>
|
|
||||||
post<{ data: Car }>("cars", payload, token),
|
|
||||||
|
|
||||||
/**
|
update: async (id: string, payload: UpdateCarPayload, token: string | null): Promise<Car> => {
|
||||||
* PATCH /cars/:id
|
const res = await patch<{ data: Car }>(`cars/${id}`, payload, token);
|
||||||
* Update an existing car's details.
|
return res.data;
|
||||||
*/
|
},
|
||||||
update: (id: string, payload: UpdateCarPayload, token: string | null) =>
|
|
||||||
patch<{ data: Car }>(`cars/${id}`, payload, token),
|
|
||||||
|
|
||||||
/**
|
delete: (id: string, token: string | null) => del<void>(`cars/${id}`, token),
|
||||||
* DELETE /cars/:id
|
|
||||||
* Soft-delete a car (returns 204 No Content).
|
|
||||||
*/
|
|
||||||
delete: (id: string, token: string | null) =>
|
|
||||||
del<void>(`cars/${id}`, token),
|
|
||||||
|
|
||||||
// ── Images ──────────────────────────────────────────────────────────────
|
getImages: async (
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /car-images/car/:id
|
|
||||||
* Fetch all active images for a car.
|
|
||||||
* Supports optional query params: day, month, year, date, sortBy.
|
|
||||||
*/
|
|
||||||
getImages: (
|
|
||||||
carId: string,
|
carId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
filters: { day?: string; month?: string; year?: string; date?: string; sortBy?: "asc" | "desc" } = {},
|
filters: { day?: string; month?: string; year?: string; date?: string; sortBy?: "asc" | "desc" } = {},
|
||||||
) =>
|
): Promise<CarImage[]> => {
|
||||||
get<CarImageListResponse>(
|
const res: CarImageListResponse = await get<CarImageListResponse>(
|
||||||
`car-images/car/${carId}${buildQuery(filters as Record<string, string>)}`,
|
`car-images/car/${carId}${buildQuery(filters as Record<string, string>)}`,
|
||||||
token,
|
token,
|
||||||
),
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
getArchivedImages: async (carId: string, token: string | null): Promise<CarImage[]> => {
|
||||||
* GET /car-images/car/:id/archive
|
const res: CarImageListResponse = await get<CarImageListResponse>(`car-images/car/${carId}/archive`, token);
|
||||||
* Fetch soft-deleted images for a car.
|
return res.data;
|
||||||
*/
|
},
|
||||||
getArchivedImages: (carId: string, token: string | null) =>
|
|
||||||
get<CarImageListResponse>(`car-images/car/${carId}/archive`, token),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /car-images/car/:id (multipart/form-data)
|
|
||||||
* Upload one or more images.
|
|
||||||
* Uses raw fetch because multer expects FormData, not JSON.
|
|
||||||
*/
|
|
||||||
uploadImages: async (
|
uploadImages: async (
|
||||||
carId: string,
|
carId: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
stage: "BEFORE" | "AFTER" | "GENERAL" = "GENERAL",
|
stage: "BEFORE" | "AFTER" | "GENERAL" = "GENERAL",
|
||||||
token: string | null,
|
token: string | null,
|
||||||
maintenanceId?: string,
|
maintenanceId?: string,
|
||||||
): Promise<CarImageListResponse> => {
|
): Promise<CarImage[]> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
files.forEach((f) => form.append("images", f));
|
files.forEach((f) => form.append("images", f));
|
||||||
form.append("stage", stage);
|
form.append("stage", stage);
|
||||||
@@ -112,18 +80,19 @@ export const carService = {
|
|||||||
body: form,
|
body: form,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const json = await res.json().catch(() => null);
|
const json = await res.json().catch(() => null);
|
||||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
return res.json() as Promise<CarImageListResponse>;
|
const data = (await res.json()) as CarImageListResponse;
|
||||||
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
deleteImage: (imageId: string, token: string | null) => del<void>(`car-images/${imageId}`, token),
|
||||||
* DELETE /car-images/:imageId
|
|
||||||
* Soft-delete a single image.
|
/** Dropdown helper — replaces raw get<T>() calls in TripFormModal / CarFormModal. */
|
||||||
*/
|
getActiveOptions: async (token: string | null): Promise<CarOption[]> => {
|
||||||
deleteImage: (imageId: string, token: string | null) =>
|
const res = await get<{ data: { data: CarOption[] } }>("cars?limit=100¤tStatus=Active", token);
|
||||||
del<void>(`car-images/${imageId}`, token),
|
return res.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -95,8 +95,19 @@ export const carMaintenanceService = {
|
|||||||
*/
|
*/
|
||||||
getAllArchivedGlobal: (token: string | null) =>
|
getAllArchivedGlobal: (token: string | null) =>
|
||||||
get<MaintenanceListResponse>("maintenance/archived", token),
|
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…)
|
// Re-exported so callers can build extra filters (day/month/year, search…)
|
||||||
// the same way carService does, without duplicating the helper.
|
// the same way carService does, without duplicating the helper.
|
||||||
export { buildQuery as buildMaintenanceQuery };
|
export { buildQuery as buildMaintenanceQuery };
|
||||||
@@ -1,42 +1,42 @@
|
|||||||
|
|
||||||
// Mirrors user.service.ts exactly: token parameter, buildPayload, buildQuery.
|
|
||||||
import { get, post, put, del } from "./api";
|
import { 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 {
|
function buildClientsQuery(page: number, search: string): string {
|
||||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clientService = {
|
export const clientService = {
|
||||||
/** جلب قائمة العملاء مع إمكانية البحث والتصفح بين الصفحات */
|
getAll: async (page: number, search: string, token: string | null): Promise<ClientListResult> => {
|
||||||
getAll: (page: number, search: string, token: string | null) =>
|
const res: ApiListResponse<Client> = await get<ApiListResponse<Client>>(
|
||||||
get<ApiListResponse<Client>>(`client${buildClientsQuery(page, search)}`, token),
|
`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: async (id: string, token: string | null): Promise<Client> => {
|
||||||
getById: (id: string, token: string | null) =>
|
const res: ApiResponse<Client> = await get<ApiResponse<Client>>(`client/${id}`, token);
|
||||||
get<ApiResponse<Client>>(`client/${id}`, token),
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/** إنشاء عميل جديد */
|
create: async (data: ClientFormData, token: string | null): Promise<Client> => {
|
||||||
create: (data: ClientFormData, token: string | null) =>
|
const res: ClientResponse = await post<ClientResponse>("client", buildPayload(data), token);
|
||||||
post<ClientResponse>("client", buildPayload(data), token),
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/** تعديل بيانات عميل موجود */
|
update: async (id: string, data: ClientFormData, token: string | null): Promise<Client> => {
|
||||||
update: (id: string, data: ClientFormData, token: string | null) =>
|
const res: ClientResponse = await put<ClientResponse>(`client/${id}`, buildPayload(data), token);
|
||||||
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> {
|
function buildPayload(data: ClientFormData): Record<string, string | boolean> {
|
||||||
const payload: Record<string, string | boolean> = {};
|
const payload: Record<string, string | boolean> = {};
|
||||||
|
|
||||||
if (data.name?.trim()) payload.name = data.name.trim();
|
if (data.name?.trim()) payload.name = data.name.trim();
|
||||||
if (data.email?.trim()) payload.email = data.email.trim();
|
if (data.email?.trim()) payload.email = data.email.trim();
|
||||||
if (data.phone?.trim()) payload.phone = data.phone.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.notes?.trim()) payload.notes = data.notes.trim();
|
||||||
if (data.clientType) payload.clientType = data.clientType;
|
if (data.clientType) payload.clientType = data.clientType;
|
||||||
if (typeof data.isActive === "boolean") payload.isActive = data.isActive;
|
if (typeof data.isActive === "boolean") payload.isActive = data.isActive;
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,9 @@ import type {
|
|||||||
UpdateAddressFormValues,
|
UpdateAddressFormValues,
|
||||||
} from "@/src/validations/client_address.validator";
|
} 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) => {
|
const collectionBase = (clientId: string) => {
|
||||||
@@ -73,6 +76,19 @@ export const clientAddressService = {
|
|||||||
setPrimary: (addressId: string, token: string | null) =>
|
setPrimary: (addressId: string, token: string | null) =>
|
||||||
patch<ApiResponse<ClientAddress>>(`${addressBase(addressId)}/primary`, {}, token),
|
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,
|
location: data.location,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default { normalizeAddress };
|
||||||
@@ -4,6 +4,9 @@ import type {
|
|||||||
DriverListResponse,
|
DriverListResponse,
|
||||||
DriverDetailResponse,
|
DriverDetailResponse,
|
||||||
DriverReportResponse,
|
DriverReportResponse,
|
||||||
|
DriverListResult,
|
||||||
|
DriverReportResult,
|
||||||
|
DriverOption,
|
||||||
CreateDriverPayload,
|
CreateDriverPayload,
|
||||||
UpdateDriverPayload,
|
UpdateDriverPayload,
|
||||||
} from "@/src/types/driver";
|
} from "@/src/types/driver";
|
||||||
@@ -15,10 +18,6 @@ function buildQuery(params: Record<string, string | number | undefined>): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Image URL normaliser ──────────────────────────────────────────────────────
|
// ── 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 {
|
function normaliseImageUrl(url: string | null | undefined): string | null {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return url;
|
return url;
|
||||||
@@ -29,7 +28,7 @@ function mapDriverImages<
|
|||||||
photoUrl?: string | null;
|
photoUrl?: string | null;
|
||||||
nationalPhotoUrl?: string | null;
|
nationalPhotoUrl?: string | null;
|
||||||
driverCardPhotoUrl?: string | null;
|
driverCardPhotoUrl?: string | null;
|
||||||
},
|
}
|
||||||
>(driver: T): T {
|
>(driver: T): T {
|
||||||
return {
|
return {
|
||||||
...driver,
|
...driver,
|
||||||
@@ -39,11 +38,7 @@ function mapDriverImages<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Image compression ─────────────────────────────────────────────────────────
|
// ── 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> {
|
async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<File> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -54,13 +49,11 @@ async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<
|
|||||||
|
|
||||||
let { width, height } = img;
|
let { width, height } = img;
|
||||||
|
|
||||||
// Guard: malformed/zero-dimension image — bail out to original file
|
|
||||||
if (!width || !height) {
|
if (!width || !height) {
|
||||||
resolve(file);
|
resolve(file);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only resize if actually oversized
|
|
||||||
if (width <= maxPx && height <= maxPx) {
|
if (width <= maxPx && height <= maxPx) {
|
||||||
resolve(file);
|
resolve(file);
|
||||||
return;
|
return;
|
||||||
@@ -81,9 +74,6 @@ async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) { resolve(file); return; }
|
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";
|
const mimeType = file.type || "image/jpeg";
|
||||||
if (mimeType !== "image/jpeg") {
|
if (mimeType !== "image/jpeg") {
|
||||||
ctx.fillStyle = "#FFFFFF";
|
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);
|
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";
|
const isLossy = mimeType === "image/jpeg" || mimeType === "image/webp";
|
||||||
|
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => {
|
(blob) => {
|
||||||
// Guard: empty/undersized blob means the encode failed — fall
|
|
||||||
// back to the original file rather than uploading garbage.
|
|
||||||
if (!blob || blob.size < 100) {
|
if (!blob || blob.size < 100) {
|
||||||
resolve(file);
|
resolve(file);
|
||||||
return;
|
return;
|
||||||
@@ -117,7 +103,7 @@ async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function compressPayloadImages<
|
async function compressPayloadImages<
|
||||||
T extends { photo?: File; nationalPhoto?: File; driverCardPhoto?: File },
|
T extends { photo?: File; nationalPhoto?: File; driverCardPhoto?: File }
|
||||||
>(payload: T): Promise<T> {
|
>(payload: T): Promise<T> {
|
||||||
const result = { ...payload };
|
const result = { ...payload };
|
||||||
if (result.photo) result.photo = await compressImage(result.photo);
|
if (result.photo) result.photo = await compressImage(result.photo);
|
||||||
@@ -129,51 +115,48 @@ async function compressPayloadImages<
|
|||||||
// ── Service ───────────────────────────────────────────────────────────────────
|
// ── Service ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const driverService = {
|
export const driverService = {
|
||||||
/** GET /driver — paginated + searchable list */
|
/** GET /driver — returns a clean, unwrapped, fully-typed result. */
|
||||||
getAll: async (page = 1, search = "", token: string | null): Promise<DriverListResponse> => {
|
getAll: async (page = 1, search = "", token: string | null): Promise<DriverListResult> => {
|
||||||
const res = await get<DriverListResponse>(
|
const res: DriverListResponse = await get<DriverListResponse>(
|
||||||
`driver${buildQuery({ page, limit: 12, search: search || undefined })}`,
|
`driver${buildQuery({ page, limit: 12, search: search || undefined })}`,
|
||||||
token,
|
token,
|
||||||
);
|
);
|
||||||
const body = (res as unknown as { data: { data: Driver[]; pagination: unknown; meta?: unknown } }).data;
|
const body = res.data;
|
||||||
body.data = body.data.map(mapDriverImages);
|
return {
|
||||||
return res;
|
items: body.data.map(mapDriverImages),
|
||||||
|
total: body.meta?.total ?? body.pagination?.total ?? 0,
|
||||||
|
pages: body.meta?.pages ?? body.pagination?.pages ?? 1,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/** GET /driver/archived */
|
/** GET /driver/archived */
|
||||||
getArchived: async (token: string | null): Promise<DriverListResponse> => {
|
getArchived: async (token: string | null): Promise<Driver[]> => {
|
||||||
const res = await get<DriverListResponse>("driver/archived", token);
|
const res: DriverListResponse = await get<DriverListResponse>("driver/archived", token);
|
||||||
const body = (res as unknown as { data: { data: Driver[] } }).data;
|
return res.data.data.map(mapDriverImages);
|
||||||
body.data = body.data.map(mapDriverImages);
|
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** GET /driver/me */
|
/** GET /driver/me */
|
||||||
getMe: async (token: string | null): Promise<DriverDetailResponse> => {
|
getMe: async (token: string | null): Promise<Driver> => {
|
||||||
const res = await get<DriverDetailResponse>("driver/me", token);
|
const res: DriverDetailResponse = await get<DriverDetailResponse>("driver/me", token);
|
||||||
const body = res as unknown as { data: Driver };
|
return mapDriverImages(res.data);
|
||||||
body.data = mapDriverImages(body.data);
|
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** GET /driver/:id */
|
/** GET /driver/:id */
|
||||||
getById: async (id: string, token: string | null): Promise<DriverDetailResponse> => {
|
getById: async (id: string, token: string | null): Promise<Driver> => {
|
||||||
const res = await get<DriverDetailResponse>(`driver/${id}`, token);
|
const res: DriverDetailResponse = await get<DriverDetailResponse>(`driver/${id}`, token);
|
||||||
const body = res as unknown as { data: Driver };
|
return mapDriverImages(res.data);
|
||||||
body.data = mapDriverImages(body.data);
|
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** POST /driver (JSON — no files) */
|
/** POST /driver (JSON — no files) */
|
||||||
create: (payload: CreateDriverPayload, token: string | null) =>
|
create: async (payload: CreateDriverPayload, token: string | null): Promise<Driver> => {
|
||||||
post<{ data: Driver }>("driver", payload, token),
|
const res = await post<{ data: Driver }>("driver", payload, token);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/** PATCH /driver/:id (JSON — no files) */
|
/** 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 res = await patch<{ data: Driver }>(`driver/${id}`, payload, token);
|
||||||
const body = res as unknown as { data: Driver };
|
return mapDriverImages(res.data);
|
||||||
body.data = mapDriverImages(body.data);
|
|
||||||
return res;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** DELETE /driver/:id */
|
/** DELETE /driver/:id */
|
||||||
@@ -181,18 +164,16 @@ export const driverService = {
|
|||||||
del<void>(`driver/${id}`, token),
|
del<void>(`driver/${id}`, token),
|
||||||
|
|
||||||
/** GET /drivers/:id/reports/daily?date=YYYY-MM-DD */
|
/** GET /drivers/:id/reports/daily?date=YYYY-MM-DD */
|
||||||
getDailyReport: (id: string, date: string, token: string | null) =>
|
getDailyReport: async (id: string, date: string, token: string | null): Promise<DriverReportResult> => {
|
||||||
get<DriverReportResponse>(
|
const res: DriverReportResponse = await get<DriverReportResponse>(
|
||||||
`drivers/${id}/reports/daily?date=${encodeURIComponent(date)}`,
|
`drivers/${id}/reports/daily?date=${encodeURIComponent(date)}`,
|
||||||
token,
|
token,
|
||||||
),
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Multipart helpers (with auto compression) ─────────────────────────────
|
// ── Multipart helpers (with auto compression) ─────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /driver (multipart/form-data)
|
|
||||||
* Compresses images before upload to stay under backend LIMIT_FILE_SIZE.
|
|
||||||
*/
|
|
||||||
createWithImages: async (
|
createWithImages: async (
|
||||||
payload: CreateDriverPayload & {
|
payload: CreateDriverPayload & {
|
||||||
photo?: File;
|
photo?: File;
|
||||||
@@ -200,7 +181,7 @@ export const driverService = {
|
|||||||
driverCardPhoto?: File;
|
driverCardPhoto?: File;
|
||||||
},
|
},
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<{ data: Driver }> => {
|
): Promise<Driver> => {
|
||||||
const compressed = await compressPayloadImages(payload);
|
const compressed = await compressPayloadImages(payload);
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -225,14 +206,9 @@ export const driverService = {
|
|||||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const data = await res.json() as { data: Driver };
|
const data = await res.json() as { data: Driver };
|
||||||
data.data = mapDriverImages(data.data);
|
return mapDriverImages(data.data);
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /driver/:id (multipart/form-data)
|
|
||||||
* Compresses images before upload to stay under backend LIMIT_FILE_SIZE.
|
|
||||||
*/
|
|
||||||
updateWithImages: async (
|
updateWithImages: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: UpdateDriverPayload & {
|
payload: UpdateDriverPayload & {
|
||||||
@@ -241,7 +217,7 @@ export const driverService = {
|
|||||||
driverCardPhoto?: File;
|
driverCardPhoto?: File;
|
||||||
},
|
},
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<{ data: Driver }> => {
|
): Promise<Driver> => {
|
||||||
const compressed = await compressPayloadImages(payload);
|
const compressed = await compressPayloadImages(payload);
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -256,7 +232,6 @@ export const driverService = {
|
|||||||
|
|
||||||
const res = await fetch(`/api/proxy/driver/${id}`, {
|
const res = await fetch(`/api/proxy/driver/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
// Do NOT set Content-Type — browser sets it with the correct boundary
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
body: form,
|
body: form,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -267,7 +242,15 @@ export const driverService = {
|
|||||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const data = await res.json() as { data: Driver };
|
const data = await res.json() as { data: Driver };
|
||||||
data.data = mapDriverImages(data.data);
|
return mapDriverImages(data.data);
|
||||||
return data;
|
},
|
||||||
|
|
||||||
|
/** Dropdown helper — replaces raw get<T>() calls in TripFormModal. */
|
||||||
|
getActiveOptions: async (token: string | null): Promise<DriverOption[]> => {
|
||||||
|
const res = await get<{ data: { data: DriverOption[] } }>(
|
||||||
|
"driver?limit=100&status=Active",
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
return res.data.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -6,32 +6,56 @@ import type {
|
|||||||
UpdateOrderPayload,
|
UpdateOrderPayload,
|
||||||
UpdateOrderStatusPayload,
|
UpdateOrderStatusPayload,
|
||||||
TransferOrderPayload,
|
TransferOrderPayload,
|
||||||
OrdersResponse,
|
OrderListResult,
|
||||||
} from "@/src/types/order";
|
} 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 = {
|
export const orderService = {
|
||||||
/** Get all orders (paginated) */
|
/** Get all orders (paginated) — returns a clean, unwrapped result. */
|
||||||
getAll: (params?: Record<string, string | number>) => {
|
getAll: async (params?: Record<string, string | number>): Promise<OrderListResult> => {
|
||||||
const qs = params
|
const qs = params
|
||||||
? "?" + new URLSearchParams(params as Record<string, string>).toString()
|
? "?" + 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 */
|
/** 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 new order */
|
||||||
create: (payload: CreateOrderPayload) =>
|
create: async (payload: CreateOrderPayload): Promise<Order> =>
|
||||||
post<Order>("orders", payload),
|
unwrapOrder(await post<Order | { data: Order }>("orders", payload)),
|
||||||
|
|
||||||
/** Update order metadata */
|
/** Update order metadata */
|
||||||
update: (id: string, payload: UpdateOrderPayload) =>
|
update: async (id: string, payload: UpdateOrderPayload): Promise<Order> =>
|
||||||
patch<Order>(`orders/${id}`, payload),
|
unwrapOrder(await patch<Order | { data: Order }>(`orders/${id}`, payload)),
|
||||||
|
|
||||||
/** Update order status with optional reason */
|
/** Update order status with optional reason */
|
||||||
updateStatus: (id: string, payload: UpdateOrderStatusPayload) =>
|
updateStatus: async (id: string, payload: UpdateOrderStatusPayload): Promise<Order> =>
|
||||||
patch<Order>(`orders/${id}/status`, payload),
|
unwrapOrder(await patch<Order | { data: Order }>(`orders/${id}/status`, payload)),
|
||||||
|
|
||||||
/** Transfer order to a different trip */
|
/** Transfer order to a different trip */
|
||||||
transfer: (id: string, payload: TransferOrderPayload) =>
|
transfer: (id: string, payload: TransferOrderPayload) =>
|
||||||
@@ -41,5 +65,8 @@ export const orderService = {
|
|||||||
delete: (id: string) => del<void>(`orders/${id}`),
|
delete: (id: string) => del<void>(`orders/${id}`),
|
||||||
|
|
||||||
/** Get archived orders */
|
/** Get archived orders */
|
||||||
getArchived: () => get<OrdersResponse>("orders/archived"),
|
getArchived: async (): Promise<Order[]> => {
|
||||||
|
const res = await get<{ data: { data: Order[] } }>("orders/archived");
|
||||||
|
return res.data.data ?? [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -1,53 +1,58 @@
|
|||||||
import {
|
import {
|
||||||
ApiPaginatedResponse,
|
ApiPaginatedResponse, AssignPermissionResponse, BulkAssignPermissionsResponse,
|
||||||
AssignPermissionResponse,
|
PermissionsResponse, Permission, Role, RoleFormData, RoleResponse, RoleListResult,
|
||||||
BulkAssignPermissionsResponse,
|
|
||||||
PermissionsResponse,
|
|
||||||
Role,
|
|
||||||
RoleFormData,
|
|
||||||
RoleResponse,
|
|
||||||
} from "../types/role";
|
} from "../types/role";
|
||||||
import { get, post, put, del, patch } from "./api";
|
import { get, post, put, del, patch } from "./api";
|
||||||
|
|
||||||
/** Build query string for paginated role listing */
|
|
||||||
function buildRolesQuery(page: number, search: string): string {
|
function buildRolesQuery(page: number, search: string): string {
|
||||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const roleService = {
|
export const roleService = {
|
||||||
/** Fetch paginated roles list */
|
getAll: async (page: number, search: string, token: string | null): Promise<RoleListResult> => {
|
||||||
getAll: (page: number, search: string, token: string | null) =>
|
const res: ApiPaginatedResponse<Role> = await get<ApiPaginatedResponse<Role>>(
|
||||||
get<ApiPaginatedResponse<Role>>(`role${buildRolesQuery(page, search)}`, token),
|
`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: async (id: string, token: string | null): Promise<Role> => {
|
||||||
getById: (id: string, token: string | null) =>
|
const res = await get<{ data: Role }>(`role/${id}`, token);
|
||||||
get<{ data: Role }>(`role/${id}`, token),
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/** Create a new role */
|
create: async (data: RoleFormData, token: string | null): Promise<Role> => {
|
||||||
create: (data: RoleFormData, token: string | null) =>
|
const res: RoleResponse = await post<RoleResponse>(
|
||||||
post<RoleResponse>("role", { name: data.name, description: data.description }, token),
|
"role", { name: data.name, description: data.description }, token,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/** Update role name/description */
|
update: async (id: string, data: Partial<RoleFormData>, token: string | null): Promise<Role> => {
|
||||||
update: (id: string, data: Partial<RoleFormData>, token: string | null) =>
|
const res: RoleResponse = await put<RoleResponse>(
|
||||||
put<RoleResponse>(`role/${id}`, { name: data.name, description: data.description }, token),
|
`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: async (token: string | null): Promise<Permission[]> => {
|
||||||
getPermissions: (token: string | null) =>
|
const res: PermissionsResponse = await get<PermissionsResponse>("premission?limit=200", token);
|
||||||
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) =>
|
assignPermission: (roleId: string, permissionId: string, token: string | null) =>
|
||||||
post<AssignPermissionResponse>(`role-permissions/${roleId}/permissions`, { permissionId }, token),
|
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) =>
|
bulkAssignPermissions: (roleId: string, permissionIds: string[], token: string | null) =>
|
||||||
patch<BulkAssignPermissionsResponse>(`role-permissions/${roleId}/permissions`, { permissionIds }, token),
|
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) =>
|
removePermission: (roleId: string, permissionId: string, token: string | null) =>
|
||||||
del<void>(`role-permissions/${roleId}/permissions/${permissionId}`, token),
|
del<void>(`role-permissions/${roleId}/permissions/${permissionId}`, token),
|
||||||
};
|
};
|
||||||
@@ -1,109 +1,68 @@
|
|||||||
import { get, post, patch, del } from "./api";
|
import { get, post, patch, del } from "./api";
|
||||||
import type {
|
import type {
|
||||||
Trip,
|
Trip, TripListResponse, TripDetailResponse, TripReportResponse,
|
||||||
TripListResponse,
|
TripListResult, TripReportResult,
|
||||||
TripDetailResponse,
|
CreateTripPayload, UpdateTripPayload, TripListParams,
|
||||||
TripReportResponse,
|
ArchivedTripListResponse, ArchivedTripResponse,
|
||||||
CreateTripPayload,
|
|
||||||
UpdateTripPayload,
|
|
||||||
TripListParams,
|
|
||||||
} from "@/src/types/trip";
|
} from "@/src/types/trip";
|
||||||
|
|
||||||
/** Build a query string for list endpoints */
|
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
function buildQuery(
|
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
||||||
params: Record<string, string | number | undefined>,
|
|
||||||
): string {
|
|
||||||
const entries = Object.entries(params).filter(
|
|
||||||
([, v]) => v !== undefined && v !== "",
|
|
||||||
);
|
|
||||||
if (!entries.length) return "";
|
if (!entries.length) return "";
|
||||||
return (
|
return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
|
||||||
"?" +
|
|
||||||
entries
|
|
||||||
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
|
||||||
.join("&")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tripService = {
|
export const tripService = {
|
||||||
/**
|
getAll: async (params: TripListParams = {}, token: string | null): Promise<TripListResult> => {
|
||||||
* GET /trip
|
const res: TripListResponse = await get<TripListResponse>(`trip${buildQuery({ ...params })}`, token);
|
||||||
* Fetch paginated + filterable + searchable trip list.
|
return {
|
||||||
* Requires: "read-trip" permission
|
items: res.data.data,
|
||||||
*/
|
total: res.data.pagination?.total ?? 0,
|
||||||
getAll: (params: TripListParams = {}, token: string | null) =>
|
pages: res.data.pagination?.totalPages ?? 1,
|
||||||
get<TripListResponse>(`trip${buildQuery({ ...params })}`, token),
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
getArchived: async (params: TripListParams = {}, token: string | null): Promise<TripListResult> => {
|
||||||
* GET /trip/archived
|
const res: ArchivedTripListResponse = await get<ArchivedTripListResponse>(
|
||||||
* Fetch soft-deleted (archived) trips. Same filters as getAll.
|
`trip/archived${buildQuery({ ...params })}`,
|
||||||
* Requires: "read-deleted-trip" permission
|
token,
|
||||||
*/
|
);
|
||||||
getArchived: (params: TripListParams = {}, token: string | null) =>
|
return {
|
||||||
get<TripListResponse>(`trip/archived${buildQuery({ ...params })}`, token),
|
items: res.data.data,
|
||||||
|
total: res.data.meta.total,
|
||||||
|
pages: res.data.meta.totalPages,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
getArchivedById: async (id: string, token: string | null): Promise<Trip> => {
|
||||||
* GET /trip/archived/:id
|
const res: ArchivedTripResponse = await get<ArchivedTripResponse>(`trip/archived/${id}`, token);
|
||||||
* Fetch a single archived trip with full details.
|
return res.data;
|
||||||
* Requires: "read-deleted-trip" permission
|
},
|
||||||
*/
|
|
||||||
getArchivedById: (id: string, token: string | null) =>
|
|
||||||
get<TripDetailResponse>(`trip/archived/${id}`, token),
|
|
||||||
|
|
||||||
/**
|
getById: async (id: string, token: string | null): Promise<Trip> => {
|
||||||
* GET /trip/:id
|
const res: TripDetailResponse = await get<TripDetailResponse>(`trip/${id}`, token);
|
||||||
* Fetch a single trip with full driver/car details.
|
return res.data;
|
||||||
* Requires: "read-trip" permission
|
},
|
||||||
*/
|
|
||||||
getById: (id: string, token: string | null) =>
|
|
||||||
get<TripDetailResponse>(`trip/${id}`, token),
|
|
||||||
|
|
||||||
/**
|
create: async (payload: CreateTripPayload, token: string | null): Promise<Trip> => {
|
||||||
* POST /trip
|
const res = await post<{ data: Trip }>("trip", payload, token);
|
||||||
* Create a new trip.
|
return res.data;
|
||||||
* 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),
|
|
||||||
|
|
||||||
/**
|
update: async (id: string, payload: UpdateTripPayload, token: string | null): Promise<Trip> => {
|
||||||
* PATCH /trip/:id
|
const res = await patch<{ data: Trip }>(`trip/${id}`, payload, token);
|
||||||
* Update trip data / status / reassign driver or car.
|
return res.data;
|
||||||
* 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),
|
|
||||||
|
|
||||||
/**
|
delete: (id: string, token: string | null) => del<void>(`trip/${id}`, token),
|
||||||
* 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),
|
|
||||||
|
|
||||||
// ── Reports ───────────────────────────────────────────────────────────────
|
getReport: async (id: string, token: string | null): Promise<TripReportResult> => {
|
||||||
|
const res: TripReportResponse = await get<TripReportResponse>(`trip/${id}/reports`, token);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
getClientReport: async (id: string, clientId: string, token: string | null): Promise<TripReportResult> => {
|
||||||
* GET /trip/:id/reports
|
const res: TripReportResponse = await get<TripReportResponse>(`trip/${id}/reports/client/${clientId}`, token);
|
||||||
* Generate the Trip Manifest report (HTML, saved server-side).
|
return res.data;
|
||||||
* 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),
|
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { get, post, put, del } from "./api";
|
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 { Role } from "@/src/types/role";
|
||||||
import type { Branch } from "@/src/types/branch";
|
import type { Branch } from "@/src/types/branch";
|
||||||
|
|
||||||
// الشكل الحقيقي لاستجابة /users/me:
|
|
||||||
// { success, message, responseAt, data: <UserMe> }
|
|
||||||
// أي "data" هنا هي بيانات اليوزر مباشرة، مفيش data.data متداخلة.
|
|
||||||
export interface MeApiResponse {
|
export interface MeApiResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -13,83 +12,74 @@ export interface MeApiResponse {
|
|||||||
data: UserMe;
|
data: UserMe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**Building a query string to fetch users with search and pagination*/
|
|
||||||
function buildUsersQuery(page: number, search: string): string {
|
function buildUsersQuery(page: number, search: string): string {
|
||||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
/** Fetching the user list with the ability to search and browse through pages*/
|
getAll: async (page: number, search: string, token: string | null): Promise<UserListResult> => {
|
||||||
getAll: (page: number, search: string, token: string | null) =>
|
const res: ApiListResponse<User> = await get<ApiListResponse<User>>(
|
||||||
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
|
`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: async (
|
||||||
create: (
|
|
||||||
data: Omit<UserFormData, "password"> & { password: string },
|
data: Omit<UserFormData, "password"> & { password: string },
|
||||||
token: string | null,
|
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: async (id: string, data: UserFormData, token: string | null): Promise<User> => {
|
||||||
update: (id: string, data: UserFormData, token: string | null) =>
|
const res: UserResponse = await put<UserResponse>(`users/${id}`, buildPayload(data, false), token);
|
||||||
put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
/** delete user*/
|
|
||||||
delete: (id: string, token: string | null) => del<void>(`users/${id}`, token),
|
delete: (id: string, token: string | null) => del<void>(`users/${id}`, token),
|
||||||
//get role
|
|
||||||
getRoles: (token: string | null) =>
|
getRoles: async (token: string | null): Promise<Role[]> => {
|
||||||
get<{ data: { data: Role[] } }>("role?limit=100", token),
|
const res = await get<{ data: { data: Role[] } }>("role?limit=100", token);
|
||||||
//get Branches for user form
|
return res.data.data;
|
||||||
getBranches: (token: string | null) =>
|
},
|
||||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token),
|
|
||||||
//get user by id
|
getBranches: async (token: string | null): Promise<Branch[]> => {
|
||||||
getById: (id: string, token: string | null) =>
|
const res = await get<{ data: { data: Branch[] } }>("branches?limit=100", token);
|
||||||
get<{
|
return res.data.data;
|
||||||
data: User & {
|
},
|
||||||
photo: string | null;
|
|
||||||
refreshToken: string | null;
|
getById: async (id: string, token: string | null): Promise<UserDetail> => {
|
||||||
isDeleted: boolean;
|
const res = await get<{ data: UserDetail }>(`users/${id}`, token);
|
||||||
deletedAt: string | null;
|
return res.data;
|
||||||
updatedAt: string;
|
},
|
||||||
passwordChangedAt: string | null;
|
|
||||||
role: { name: string; description: string };
|
getMe: (token: string | null) => get<MeApiResponse>("users/me", token),
|
||||||
branch: { name: string };
|
|
||||||
};
|
|
||||||
}>(`users/${id}`, token),
|
|
||||||
getMe: (token: string | null) =>
|
|
||||||
get<MeApiResponse>("users/me", token),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* بتفكك استجابة /users/me لأي شكل ييجي بيه من السيرفر:
|
* One-time defensive unwrap for /users/me, which has been observed to
|
||||||
* - سواء الـ get() بيرجع الـ body مباشرة: { success, data: <user> }
|
* return either { success, data } directly or { data: { success, data } }.
|
||||||
* - أو بيرجع wrapper شكله axios-like: { data: { success, data: <user> } }
|
* Documented and isolated here — nothing outside the service ever sees
|
||||||
* فبناخد أول data موجودة (لو فيها success/message نبقى في المكان الصح)،
|
* the ambiguity.
|
||||||
* وبعدين ناخد الـ data اللي جواها (بيانات اليوزر الفعلية).
|
|
||||||
* ملحوظة: مفيش تفكيك مزدوج بره الدالة دي، وأي حد بينادي عليها
|
|
||||||
* لازم يبعتلها الـ response زي ما هو من غير ما يعمل res.data قبلها.
|
|
||||||
*/
|
*/
|
||||||
export function extractMeUser(res: MeApiResponse | { data: MeApiResponse }): UserMe | null {
|
export function extractMeUser(res: MeApiResponse | { data: MeApiResponse }): UserMe | null {
|
||||||
const body: MeApiResponse = (res as { data: MeApiResponse })?.data?.data
|
const body: MeApiResponse = (res as { data: MeApiResponse })?.data?.data
|
||||||
? (res as { data: MeApiResponse }).data
|
? (res as { data: MeApiResponse }).data
|
||||||
: (res as MeApiResponse);
|
: (res as MeApiResponse);
|
||||||
|
|
||||||
const d = body?.data;
|
const d = body?.data;
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
if (Array.isArray(d)) return (d[0] as UserMe) ?? 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> = {
|
const payload: Record<string, string> = {
|
||||||
name: data.name.trim(),
|
name: data.name.trim(), phone: data.phone.trim(), roleId: data.roleId, branchId: data.branchId,
|
||||||
phone: data.phone.trim(),
|
|
||||||
roleId: data.roleId,
|
|
||||||
branchId: data.branchId,
|
|
||||||
};
|
};
|
||||||
if (data.email) payload.email = data.email.trim();
|
if (data.email) payload.email = data.email.trim();
|
||||||
if (isNew && data.password) payload.password = data.password;
|
if (isNew && data.password) payload.password = data.password;
|
||||||
|
|||||||
@@ -121,3 +121,14 @@ export interface ArchivedBranchListResponse {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BranchListResult {
|
||||||
|
items: Branch[];
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -222,4 +222,18 @@ export interface CarFormErrors {
|
|||||||
gpsDeviceId?: string;
|
gpsDeviceId?: string;
|
||||||
capacity?: string;
|
capacity?: string;
|
||||||
weight?: 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;
|
||||||
}
|
}
|
||||||
@@ -154,3 +154,8 @@ export interface ArchivedClientOrdersResponse {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface ClientListResult {
|
||||||
|
items: Client[];
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ export interface Driver {
|
|||||||
driverCardPhotoUrl?: string | null;
|
driverCardPhotoUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface DriverStatusHistoryEntry {
|
export interface DriverStatusHistoryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
status: DriverStatus;
|
status: DriverStatus;
|
||||||
@@ -184,3 +185,23 @@ export interface ArchivedDriverResponse {
|
|||||||
export interface ArchivedDriverStatusHistoryResponse {
|
export interface ArchivedDriverStatusHistoryResponse {
|
||||||
data: DriverStatusHistoryEntry[];
|
data: DriverStatusHistoryEntry[];
|
||||||
}
|
}
|
||||||
|
// ── Clean, unwrapped shapes returned by driverService ──
|
||||||
|
|
||||||
|
export interface DriverListResult {
|
||||||
|
items: Driver[];
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverReportResult {
|
||||||
|
reportUrl: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal shape used by dropdowns (TripFormModal etc.) */
|
||||||
|
export interface DriverOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
status: DriverStatus;
|
||||||
|
}
|
||||||
@@ -135,5 +135,14 @@ export interface ArchivedOrderListResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
responseAt: string;
|
responseAt: string;
|
||||||
data: ArchivedOrder[];
|
data: {
|
||||||
|
data: ArchivedOrder[];
|
||||||
|
meta?: { total: number; page: number; limit: number; totalPages: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderListResult {
|
||||||
|
items: Order[];
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ export interface RoleResponse {
|
|||||||
|
|
||||||
export interface PermissionsResponse {
|
export interface PermissionsResponse {
|
||||||
data: {
|
data: {
|
||||||
premissions: Permission[];
|
premissions: { data: Permission[] };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +94,17 @@ export interface ArchivedRoleResponse {
|
|||||||
data: ArchivedRole;
|
data: ArchivedRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /role/archived — NOTE: unlike users/branches archive endpoints,
|
// GET /role/archived — like the other archive endpoints, this returns
|
||||||
// this one returns a plain array with no pagination/meta wrapper.
|
// { data: { data: ArchivedRole[], meta: {...} } }.
|
||||||
// Search & pagination are therefore handled client-side in the hook.
|
// Search & pagination are still handled client-side in the hook.
|
||||||
export interface ArchivedRoleListResponse {
|
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;
|
||||||
}
|
}
|
||||||
@@ -173,3 +173,12 @@ export interface ArchivedTripListResponse {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface TripListResult {
|
||||||
|
items: Trip[];
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripReportResult {
|
||||||
|
reportUrl: string;
|
||||||
|
}
|
||||||
@@ -138,3 +138,8 @@ export interface UserMe extends User {
|
|||||||
role?: UserRoleDetail | null;
|
role?: UserRoleDetail | null;
|
||||||
branch?: { id?: string; name: string } | null;
|
branch?: { id?: string; name: string } | null;
|
||||||
}
|
}
|
||||||
|
export interface UserListResult {
|
||||||
|
items: User[];
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user