add archive endpoint to user , driver , car ,order ,trip and update trip fails also update saidecar
This commit is contained in:
@@ -7,8 +7,7 @@ import { getStoredUser } from "@/src/lib/auth";
|
|||||||
import Logo from "@/src/utils/logo";
|
import Logo from "@/src/utils/logo";
|
||||||
import BrandIcon from "@/src/utils/brandIcon";
|
import BrandIcon from "@/src/utils/brandIcon";
|
||||||
|
|
||||||
|
//ــــــــــــــ Drawer Context
|
||||||
//ــــــــــــــ Drawer Context
|
|
||||||
|
|
||||||
const DrawerCtx = createContext<{
|
const DrawerCtx = createContext<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -35,26 +34,82 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: "الرئيسية",
|
label: "الرئيسية",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: "ti-layout-dashboard", permission: "read-dashboard" },
|
{
|
||||||
{ href: "/dashboard/users", label: "Users", icon: "ti-users", permission: "read-user" },
|
href: "/dashboard",
|
||||||
|
label: "لوحة التحكم",
|
||||||
|
icon: "ti-layout-dashboard",
|
||||||
|
permission: "read-dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/users",
|
||||||
|
label: "المستخدمون",
|
||||||
|
icon: "ti-users",
|
||||||
|
permission: "read-user",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "الأسطول",
|
label: "الأسطول",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/dashboard/cars", label: "Cars", icon: "ti-car", permission: "read-car" },
|
{
|
||||||
{ href: "/dashboard/drivers", label: "Drivers", icon: "ti-steering-wheel", permission: "read-driver" },
|
href: "/dashboard/cars",
|
||||||
{ href: "/dashboard/trips", label: "Trips", icon: "ti-route", permission: "read-trip" },
|
label: "السيارات",
|
||||||
|
icon: "ti-car",
|
||||||
|
permission: "read-car",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/cars_maintenance",
|
||||||
|
label: "صيانة السيارات",
|
||||||
|
icon: "ti-tool",
|
||||||
|
permission: "read-maintenance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/drivers",
|
||||||
|
label: "السائقون",
|
||||||
|
icon: "ti-steering-wheel",
|
||||||
|
permission: "read-driver",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/trips",
|
||||||
|
label: "الرحلات",
|
||||||
|
icon: "ti-route",
|
||||||
|
permission: "read-trip",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "العمليات",
|
label: "العمليات",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/dashboard/orders", label: "Orders", icon: "ti-package", permission: "read-order" },
|
{
|
||||||
{ href: "/dashboard/clients", label: "Clients", icon: "ti-users-group", permission: "read-client" },
|
href: "/dashboard/orders",
|
||||||
{ href: "/dashboard/branches", label: "Branches", icon: "ti-building", permission: "read-branch" },
|
label: "الطلبات",
|
||||||
{ href: "/dashboard/roles", label: "Roles", icon: "ti-shield", permission: "read-role" },
|
icon: "ti-package",
|
||||||
{ href: "/dashboard/audit", label: "Audit", icon: "ti-clipboard-list", permission: "read-audit" },
|
permission: "read-order",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/clients",
|
||||||
|
label: "العملاء",
|
||||||
|
icon: "ti-users-group",
|
||||||
|
permission: "read-client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/branches",
|
||||||
|
label: "الفروع",
|
||||||
|
icon: "ti-building",
|
||||||
|
permission: "read-branch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/roles",
|
||||||
|
label: "الادوار",
|
||||||
|
icon: "ti-shield",
|
||||||
|
permission: "read-role",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/audit",
|
||||||
|
label: "سجل العمليات",
|
||||||
|
icon: "ti-clipboard-list",
|
||||||
|
permission: "read-audit",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -63,7 +118,6 @@ const navSections = [
|
|||||||
Brand Icon
|
Brand Icon
|
||||||
══════════════════════════════════════════ */
|
══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
|
||||||
export function BrandIconButton({ onClick }: { onClick: () => void }) {
|
export function BrandIconButton({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -74,9 +128,12 @@ export function BrandIconButton({ onClick }: { onClick: () => void }) {
|
|||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
width: 36, height: 36,
|
width: 36,
|
||||||
|
height: 36,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
@@ -94,16 +151,22 @@ const GRAD = "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)";
|
|||||||
globals.css controls ALL display with !important
|
globals.css controls ALL display with !important
|
||||||
══════════════════════════════════════════ */
|
══════════════════════════════════════════ */
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const user = getStoredUser();
|
const user = getStoredUser();
|
||||||
const permissions = user?.permissions ?? navSections.flatMap(s => s.items.map(i => i.permission));
|
const permissions =
|
||||||
|
user?.permissions ??
|
||||||
|
navSections.flatMap((s) => s.items.map((i) => i.permission));
|
||||||
const { open, setOpen } = useSidebarDrawer();
|
const { open, setOpen } = useSidebarDrawer();
|
||||||
|
|
||||||
useEffect(() => { setOpen(false); }, [pathname, setOpen]);
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [pathname, setOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = open ? "hidden" : "";
|
document.body.style.overflow = open ? "hidden" : "";
|
||||||
return () => { document.body.style.overflow = ""; };
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,14 +177,19 @@ export function Sidebar() {
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
style={{
|
style={{
|
||||||
background: GRAD,
|
background: GRAD,
|
||||||
flexDirection: "column", /* NO display property */
|
flexDirection: "column" /* NO display property */,
|
||||||
width: "var(--sidebar-width)",
|
width: "var(--sidebar-width)",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
padding: "20px 12px",
|
padding: "20px 12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SidebarContent pathname={pathname} permissions={permissions} user={user} compact={false} />
|
<SidebarContent
|
||||||
|
pathname={pathname}
|
||||||
|
permissions={permissions}
|
||||||
|
user={user}
|
||||||
|
compact={false}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ══ 2. Compact rail */}
|
{/* ══ 2. Compact rail */}
|
||||||
@@ -131,7 +199,7 @@ export function Sidebar() {
|
|||||||
aria-label="التنقل المصغر"
|
aria-label="التنقل المصغر"
|
||||||
style={{
|
style={{
|
||||||
background: GRAD,
|
background: GRAD,
|
||||||
flexDirection: "column", /* NO display property */
|
flexDirection: "column" /* NO display property */,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -144,7 +212,12 @@ export function Sidebar() {
|
|||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SidebarContent pathname={pathname} permissions={permissions} user={user} compact={true} />
|
<SidebarContent
|
||||||
|
pathname={pathname}
|
||||||
|
permissions={permissions}
|
||||||
|
user={user}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* mobile drower*/}
|
{/* mobile drower*/}
|
||||||
@@ -156,7 +229,10 @@ export function Sidebar() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: 0, left: 0, right: 0, bottom: 0,
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
background: "rgba(0,0,0,0.5)",
|
background: "rgba(0,0,0,0.5)",
|
||||||
zIndex: 40,
|
zIndex: 40,
|
||||||
}}
|
}}
|
||||||
@@ -170,7 +246,7 @@ export function Sidebar() {
|
|||||||
aria-label="قائمة التنقل"
|
aria-label="قائمة التنقل"
|
||||||
style={{
|
style={{
|
||||||
background: GRAD,
|
background: GRAD,
|
||||||
flexDirection: "column", /* NO display property */
|
flexDirection: "column" /* NO display property */,
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -180,7 +256,7 @@ export function Sidebar() {
|
|||||||
padding: "20px 12px",
|
padding: "20px 12px",
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
|
|
||||||
transform: open ? "translateX(0)" : "translateX(100%)",
|
transform: open ? "translateX(0)" : "translateX(100%)",
|
||||||
transition: "transform 280ms cubic-bezier(0.4,0,0.2,1)",
|
transition: "transform 280ms cubic-bezier(0.4,0,0.2,1)",
|
||||||
}}
|
}}
|
||||||
@@ -197,19 +273,34 @@ export function Sidebar() {
|
|||||||
background: "rgba(255,255,255,0.12)",
|
background: "rgba(255,255,255,0.12)",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
width: 30, height: 30,
|
width: 30,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
height: 30,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
<svg
|
||||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
width="13"
|
||||||
<path d="M18 6 6 18M6 6l12 12"/>
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SidebarContent pathname={pathname} permissions={permissions} user={user} compact={false} />
|
<SidebarContent
|
||||||
|
pathname={pathname}
|
||||||
|
permissions={permissions}
|
||||||
|
user={user}
|
||||||
|
compact={false}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -219,7 +310,10 @@ export function Sidebar() {
|
|||||||
SidebarContent
|
SidebarContent
|
||||||
══════════════════════════════════════════ */
|
══════════════════════════════════════════ */
|
||||||
function SidebarContent({
|
function SidebarContent({
|
||||||
pathname, permissions, user, compact,
|
pathname,
|
||||||
|
permissions,
|
||||||
|
user,
|
||||||
|
compact,
|
||||||
}: {
|
}: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
@@ -228,28 +322,52 @@ function SidebarContent({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: 28, display: "flex", justifyContent: compact ? "center" : "flex-start" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 28,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: compact ? "center" : "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{compact ? <BrandIcon size={36} /> : <Logo white />}
|
{compact ? <BrandIcon size={36} /> : <Logo white />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, display: "flex", flexDirection: "column", width: "100%" }}
|
<nav
|
||||||
aria-label="Main navigation">
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
{navSections.map((section) => (
|
{navSections.map((section) => (
|
||||||
<div key={section.label} style={{ marginBottom: 8 }}>
|
<div key={section.label} style={{ marginBottom: 8 }}>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<p style={{
|
<p
|
||||||
fontSize: 9, letterSpacing: "0.14em", textTransform: "uppercase",
|
style={{
|
||||||
color: "rgba(255,255,255,0.45)", padding: "8px 8px 4px",
|
fontSize: 9,
|
||||||
fontWeight: 600, textAlign: "start", margin: 0,
|
letterSpacing: "0.14em",
|
||||||
}}>
|
textTransform: "uppercase",
|
||||||
|
color: "rgba(255,255,255,0.45)",
|
||||||
|
padding: "8px 8px 4px",
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: "start",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{section.label}
|
{section.label}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const allowed = item.permission === "read-dashboard" || permissions.includes(item.permission);
|
const allowed =
|
||||||
|
item.permission === "read-dashboard" ||
|
||||||
|
permissions.includes(item.permission);
|
||||||
if (!allowed) return null;
|
if (!allowed) return null;
|
||||||
const active = pathname === item.href
|
const active =
|
||||||
|| (item.href !== "/dashboard" && pathname.startsWith(item.href));
|
pathname === item.href ||
|
||||||
|
(item.href !== "/dashboard" &&
|
||||||
|
pathname.startsWith(item.href + "/"));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
@@ -266,15 +384,20 @@ function SidebarContent({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: active ? 500 : 400,
|
fontWeight: active ? 500 : 400,
|
||||||
color: active ? "#FFFFFF" : "rgba(255,255,255,0.55)",
|
color: active ? "#FFFFFF" : "rgba(255,255,255,0.55)",
|
||||||
background: active ? "rgba(255,255,255,0.15)" : "transparent",
|
background: active
|
||||||
|
? "rgba(255,255,255,0.15)"
|
||||||
|
: "transparent",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
transition: "var(--transition-base)",
|
transition: "var(--transition-base)",
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
textAlign: "start",
|
textAlign: "start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i className={`ti ${item.icon}`} aria-hidden="true"
|
<i
|
||||||
style={{ fontSize: compact ? 18 : 16, flexShrink: 0 }} />
|
className={`ti ${item.icon}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ fontSize: compact ? 18 : 16, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
{!compact && item.label}
|
{!compact && item.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -288,27 +411,56 @@ function SidebarContent({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
title={user?.name ?? user?.userName ?? "—"}
|
title={user?.name ?? user?.userName ?? "—"}
|
||||||
style={{
|
style={{
|
||||||
width: 36, height: 36, borderRadius: "var(--radius-full)",
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
background: "rgba(255,255,255,0.18)",
|
background: "rgba(255,255,255,0.18)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex",
|
||||||
fontSize: 13, fontWeight: 600, color: "#fff", marginTop: 8,
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#fff",
|
||||||
|
marginTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(user?.name ?? user?.userName ?? "—").slice(0, 1)}
|
{(user?.name ?? user?.userName ?? "—").slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div
|
||||||
background: "rgba(255,255,255,0.12)", borderRadius: 10,
|
style={{
|
||||||
padding: "10px 12px", marginTop: 8, width: "100%",
|
background: "rgba(255,255,255,0.12)",
|
||||||
}}>
|
borderRadius: 10,
|
||||||
<p suppressHydrationWarning style={{ fontSize: 13, fontWeight: 500, color: "#fff", margin: 0, textAlign: "start" }}>
|
padding: "10px 12px",
|
||||||
|
marginTop: 8,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
suppressHydrationWarning
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#fff",
|
||||||
|
margin: 0,
|
||||||
|
textAlign: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{user?.name ?? user?.userName ?? "—"}
|
{user?.name ?? user?.userName ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
<p suppressHydrationWarning style={{ fontSize: 11, color: "rgba(255,255,255,0.55)", marginTop: 2, textAlign: "start" }}>
|
<p
|
||||||
|
suppressHydrationWarning
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "rgba(255,255,255,0.55)",
|
||||||
|
marginTop: 2,
|
||||||
|
textAlign: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{user?.role ?? "Signed in"}
|
{user?.role ?? "Signed in"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
7
app/dashboard/cars_maintenance/page.tsx
Normal file
7
app/dashboard/cars_maintenance/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
return (
|
||||||
|
<div>page</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,10 +17,16 @@ function DashShell({ children }: { children: React.ReactNode }) {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
if (w >= 768 && w < 1024) {
|
if (w >= 768 && w < 1024) {
|
||||||
|
// #sb-compact is `position: fixed; right: 0` (inline-start
|
||||||
el.style.paddingInlineEnd = "72px";
|
// in RTL), so the content needs its reserved space on
|
||||||
|
// paddingInlineSTART — not End. End resolved to the wrong
|
||||||
|
// physical side (left) and let the fixed rail sit on top
|
||||||
|
// of the content on md screens. Using the CSS var instead
|
||||||
|
// of a hardcoded "72px" also keeps this in sync with
|
||||||
|
// --sidebar-width-compact if that token ever changes.
|
||||||
|
el.style.paddingInlineStart = "var(--sidebar-width-compact)";
|
||||||
} else {
|
} else {
|
||||||
el.style.paddingInlineEnd = "0px";
|
el.style.paddingInlineStart = "0px";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
113
app/dashboard/trips/archived/[tripId]/page.tsx
Normal file
113
app/dashboard/trips/archived/[tripId]/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Spinner, EmptyState, Badge, PageHeader } from "@/src/Components/UI";
|
||||||
|
import { tripService } from "@/src/services/trip.service";
|
||||||
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
import type { Trip } from "@/src/types/trip";
|
||||||
|
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||||
|
|
||||||
|
function fmtDateTime(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleString("ar-SA", { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArchivedTripDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const tripId = params?.tripId as string;
|
||||||
|
|
||||||
|
const [trip, setTrip] = useState<Trip | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tripId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
// Archived trips must go through the archived endpoint —
|
||||||
|
// the normal /trip/:id endpoint won't return soft-deleted trips.
|
||||||
|
const res = await tripService.getArchivedById(tripId, token);
|
||||||
|
if (!cancelled) setTrip((res as unknown as { data: Trip }).data);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("لم يتم العثور على هذه الرحلة في الأرشيف.");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [tripId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-3 py-24 text-[var(--color-text-muted)]">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span className="text-sm">جارٍ التحميل…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge case: invalid/missing/unreachable trip ID
|
||||||
|
if (error || !trip) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="🗄️"
|
||||||
|
title="الرحلة غير موجودة في الأرشيف"
|
||||||
|
description={error ?? "تعذّر العثور على رحلة بهذا المعرّف."}
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/trips")}
|
||||||
|
className="text-sm font-semibold text-[var(--color-brand-600)] underline"
|
||||||
|
>
|
||||||
|
العودة إلى قائمة الرحلات
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = TRIP_STATUS_MAP[trip.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-6 p-6" dir="rtl">
|
||||||
|
<PageHeader
|
||||||
|
title={trip.title}
|
||||||
|
description={`رقم الرحلة: ${trip.tripNumber}`}
|
||||||
|
backHref="/dashboard/trips"
|
||||||
|
backLabel="الرحلات"
|
||||||
|
action={<Badge label="مؤرشفة" color="amber" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-card)]">
|
||||||
|
<Badge label={statusConfig.label} color="slate" />
|
||||||
|
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-[var(--color-text-muted)]">وقت البدء</dt>
|
||||||
|
<dd>{fmtDateTime(trip.startTime)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-[var(--color-text-muted)]">وقت الانتهاء</dt>
|
||||||
|
<dd>{fmtDateTime(trip.endTime)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-[var(--color-text-muted)]">تاريخ الأرشفة</dt>
|
||||||
|
<dd>{fmtDateTime(trip.deletedAt)}</dd>
|
||||||
|
</div>
|
||||||
|
{trip.driver && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-[var(--color-text-muted)]">السائق</dt>
|
||||||
|
<dd>{trip.driver.name}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
278
src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx
Normal file
278
src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import { CarMaintenanceFormModal } from "./CarMaintananceFormModal";
|
||||||
|
import { CarMaintenanceDeleteModal } from "./CarMaintananceDeleteModal";
|
||||||
|
import {
|
||||||
|
useCarMaintenanceList,
|
||||||
|
useCarMaintenanceMutations,
|
||||||
|
useMaintenanceToast,
|
||||||
|
} from "@/src/hooks/UseCarsMaintanance";
|
||||||
|
import {
|
||||||
|
MAINTENANCE_STATUS_MAP,
|
||||||
|
fmtDate,
|
||||||
|
fmtCost,
|
||||||
|
durationDays,
|
||||||
|
getMaintenanceStatus,
|
||||||
|
} from "@/src/types/carMaintanance";
|
||||||
|
import type {
|
||||||
|
CarMaintenance,
|
||||||
|
CreateMaintenancePayload,
|
||||||
|
UpdateMaintenancePayload,
|
||||||
|
} from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Small floating message that confirms an action worked (or explains why it didn't).
|
||||||
|
|
||||||
|
function MaintenanceToast({ notification }: { notification: { type: "success" | "error"; message: string } | null }) {
|
||||||
|
if (!notification) return null;
|
||||||
|
const ok = notification.type === "success";
|
||||||
|
return (
|
||||||
|
<div role="status" aria-live="polite" style={{
|
||||||
|
position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)",
|
||||||
|
zIndex: 9999, pointerEvents: "none",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
|
padding: "0.75rem 1.25rem",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
background: ok ? "#065F46" : "#7F1D1D",
|
||||||
|
color: "#FFF", fontSize: 13, fontWeight: 600,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,.25)",
|
||||||
|
maxWidth: "90vw", whiteSpace: "nowrap",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
|
||||||
|
<span>{notification.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Record card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MaintenanceCard({
|
||||||
|
record,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
record: CarMaintenance;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "1rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "0.6rem",
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
{record.reason}
|
||||||
|
</p>
|
||||||
|
<span style={{
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: `1px solid ${status.border}`,
|
||||||
|
background: status.bg,
|
||||||
|
padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 11, fontWeight: 700, color: status.color,
|
||||||
|
whiteSpace: "nowrap", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.4rem", fontSize: 12 }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التكلفة</span>
|
||||||
|
<span style={{ color: "var(--color-text-primary)" }}>{fmtCost(record.cost)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>المدة</span>
|
||||||
|
<span style={{ color: "var(--color-text-primary)" }}>{durationDays(record.startAt, record.endAt)} يوم</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>تاريخ البدء</span>
|
||||||
|
<span style={{ color: "var(--color-text-primary)" }}>{fmtDate(record.startAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>تاريخ الانتهاء</span>
|
||||||
|
<span style={{ color: "var(--color-text-primary)" }}>{fmtDate(record.endAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", paddingTop: "0.25rem" }}>
|
||||||
|
<button type="button" onClick={onEdit}
|
||||||
|
style={{ flex: 1, height: 32, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
|
||||||
|
تعديل
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onDelete}
|
||||||
|
style={{ height: 32, padding: "0 0.875rem", borderRadius: "var(--radius-md)", border: "1px solid #FECACA", background: "#FEF2F2", fontSize: 12, fontWeight: 600, color: "#DC2626", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CarMaintenanceDetailPanelProps {
|
||||||
|
carId: string;
|
||||||
|
/** e.g. "تويوتا لاند كروزر — أ ب ج 1234", shown in the header. */
|
||||||
|
carLabel?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CarMaintenanceDetailPanel({ carId, carLabel, onClose }: CarMaintenanceDetailPanelProps) {
|
||||||
|
const { records, loading, error, loadRecords, removeRecord } = useCarMaintenanceList(carId);
|
||||||
|
const { toast, notify } = useMaintenanceToast();
|
||||||
|
|
||||||
|
const [formTarget, setFormTarget] = useState<CarMaintenance | null | false>(false); // false = closed
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CarMaintenance | null>(null);
|
||||||
|
|
||||||
|
const getEditTarget = useCallback(
|
||||||
|
() => (formTarget instanceof Object && formTarget !== null ? (formTarget as CarMaintenance) : null),
|
||||||
|
[formTarget],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMaintenanceMutations({
|
||||||
|
carId,
|
||||||
|
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadRecords(); },
|
||||||
|
onError: (msg) => notify({ type: "error", message: msg }),
|
||||||
|
onDeleted: (id) => { removeRecord(id); setDeleteTarget(null); },
|
||||||
|
getEditTarget,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape" && formTarget === false && !deleteTarget) onClose(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [formTarget, deleteTarget, onClose]);
|
||||||
|
|
||||||
|
const totalCost = records.reduce((sum, r) => sum + (r.cost ?? 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MaintenanceToast notification={toast} />
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 40, background: "rgba(15,23,42,0.45)", backdropFilter: "blur(2px)" }} />
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<aside
|
||||||
|
aria-label="سجل صيانة المركبة"
|
||||||
|
style={{
|
||||||
|
position: "fixed", top: 0, left: 0, bottom: 0, zIndex: 50,
|
||||||
|
width: "min(480px, 100vw)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
boxShadow: "8px 0 40px rgba(0,0,0,.18)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: "1.25rem 1.5rem", borderBottom: "1px solid var(--color-border)", background: "var(--color-surface-muted)", flexShrink: 0 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||||
|
سجل الصيانة
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||||
|
{carLabel ?? "المركبة"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="إغلاق"
|
||||||
|
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && records.length > 0 && (
|
||||||
|
<p style={{ marginTop: 10, fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
{records.length} سجل · إجمالي التكلفة {fmtCost(totalCost)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }} dir="rtl">
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
||||||
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ التحميل…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ borderRadius: "var(--radius-md)", background: "#FEF2F2", border: "1px solid #FECACA", padding: "0.75rem 1rem", fontSize: 13, color: "#DC2626" }}>
|
||||||
|
⚠ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && records.length === 0 && (
|
||||||
|
<div style={{ textAlign: "center", padding: "3rem 0" }}>
|
||||||
|
<div style={{ fontSize: 40, marginBottom: 10 }}>🔧</div>
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)" }}>لا توجد سجلات صيانة بعد</p>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--color-text-hint)", marginTop: 4 }}>
|
||||||
|
اضغط على "إضافة سجل" لتسجيل أول عملية صيانة لهذه المركبة.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && records.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
|
{records.map((record) => (
|
||||||
|
<MaintenanceCard
|
||||||
|
key={record.id}
|
||||||
|
record={record}
|
||||||
|
onEdit={() => setFormTarget(record)}
|
||||||
|
onDelete={() => setDeleteTarget(record)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", flexShrink: 0 }}>
|
||||||
|
<button type="button" onClick={() => setFormTarget(null)}
|
||||||
|
style={{ width: "100%", height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
إضافة سجل صيانة
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Nested modals */}
|
||||||
|
{formTarget !== false && (
|
||||||
|
<CarMaintenanceFormModal
|
||||||
|
editRecord={formTarget}
|
||||||
|
carLabel={carLabel}
|
||||||
|
onClose={() => setFormTarget(false)}
|
||||||
|
onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) =>
|
||||||
|
handleFormSubmit(payload, isNew)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<CarMaintenanceDeleteModal
|
||||||
|
record={deleteTarget}
|
||||||
|
deleting={deleting}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => handleDeleteConfirm(deleteTarget)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
src/Components/Car_Maintanance/CarMaintananceFormModal.tsx
Normal file
300
src/Components/Car_Maintanance/CarMaintananceFormModal.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import { Alert, Spinner } from "../UI";
|
||||||
|
import {
|
||||||
|
createMaintenanceSchema,
|
||||||
|
updateMaintenanceSchema,
|
||||||
|
} from "@/src/validations/carMaintanance.validator";
|
||||||
|
import type {
|
||||||
|
CarMaintenance,
|
||||||
|
CreateMaintenancePayload,
|
||||||
|
MaintenanceFormErrors,
|
||||||
|
UpdateMaintenancePayload,
|
||||||
|
} from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
|
// ── Shared input style ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const inputBase: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
height: 40,
|
||||||
|
padding: "0 0.75rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorTextStyle: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-danger)",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── yup validation ────────────────────────────────────────────────────────────
|
||||||
|
// Checks the form values against the right schema and turns any problems
|
||||||
|
// into a simple field -> message map the inputs below can read from.
|
||||||
|
|
||||||
|
async function validate(
|
||||||
|
form: Partial<CreateMaintenancePayload>,
|
||||||
|
isNew: boolean,
|
||||||
|
): Promise<MaintenanceFormErrors> {
|
||||||
|
const schema = isNew ? createMaintenanceSchema : updateMaintenanceSchema;
|
||||||
|
try {
|
||||||
|
await schema.validate(form, { abortEarly: false });
|
||||||
|
return {};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof yup.ValidationError) {
|
||||||
|
return err.inner.reduce<MaintenanceFormErrors>((acc, e) => {
|
||||||
|
const field = e.path as keyof MaintenanceFormErrors;
|
||||||
|
if (field && !acc[field]) acc[field] = e.message;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date helper ───────────────────────────────────────────────────────────────
|
||||||
|
// <input type="date"> gives "YYYY-MM-DD"; the backend expects full ISO-8601.
|
||||||
|
|
||||||
|
function toIsoDateTime(val: string): string {
|
||||||
|
if (!val) return val;
|
||||||
|
if (val.includes("T")) return val;
|
||||||
|
return `${val}T00:00:00.000Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CarMaintenanceFormModalProps {
|
||||||
|
/** Pass null to create a brand new record, or an existing record to edit it. */
|
||||||
|
editRecord: CarMaintenance | null;
|
||||||
|
/** Shown in the header so the person knows which car this belongs to. */
|
||||||
|
carLabel?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (
|
||||||
|
payload: CreateMaintenancePayload | UpdateMaintenancePayload,
|
||||||
|
isNew: boolean,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CarMaintenanceFormModal({
|
||||||
|
editRecord,
|
||||||
|
carLabel,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: CarMaintenanceFormModalProps) {
|
||||||
|
const isNew = editRecord === null;
|
||||||
|
|
||||||
|
// ── Form state ─────────────────────────────────────────────────────────────
|
||||||
|
const [reason, setReason] = useState(editRecord?.reason ?? "");
|
||||||
|
const [cost, setCost] = useState<number | undefined>(editRecord?.cost ?? undefined);
|
||||||
|
const [startAt, setStartAt] = useState(editRecord?.startAt?.slice(0, 10) ?? "");
|
||||||
|
const [endAt, setEndAt] = useState(editRecord?.endAt?.slice(0, 10) ?? "");
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<MaintenanceFormErrors>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [apiError, setApiError] = useState("");
|
||||||
|
const firstRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const parseNum = (v: string): number | undefined =>
|
||||||
|
v.trim() === "" ? undefined : Number(v);
|
||||||
|
|
||||||
|
const inputStyle = (field: keyof MaintenanceFormErrors): React.CSSProperties => ({
|
||||||
|
...inputBase,
|
||||||
|
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFieldError = (field: keyof MaintenanceFormErrors) =>
|
||||||
|
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||||
|
|
||||||
|
// ── Submit ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Build a snapshot for yup — include everything so optional rules also run.
|
||||||
|
const formSnapshot: Partial<CreateMaintenancePayload> = {
|
||||||
|
reason,
|
||||||
|
cost,
|
||||||
|
...(startAt && { startAt }),
|
||||||
|
...(endAt && { endAt }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const errs = await validate(formSnapshot, isNew);
|
||||||
|
if (Object.keys(errs).length) {
|
||||||
|
setErrors(errs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the final payload — dates go out as full ISO-8601 strings.
|
||||||
|
const raw: Record<string, unknown> = { reason, cost };
|
||||||
|
if (startAt) raw.startAt = toIsoDateTime(startAt);
|
||||||
|
if (endAt) raw.endAt = toIsoDateTime(endAt);
|
||||||
|
|
||||||
|
const payload = raw as unknown as CreateMaintenancePayload;
|
||||||
|
setSaving(true);
|
||||||
|
setApiError("");
|
||||||
|
const ok = await onSubmit(payload, isNew);
|
||||||
|
setSaving(false);
|
||||||
|
if (ok) onClose();
|
||||||
|
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="maintenance-modal-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 50,
|
||||||
|
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: "1rem", overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%", maxWidth: 520,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-2xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||||
|
overflow: "hidden",
|
||||||
|
margin: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
padding: "1.25rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||||
|
{isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"}
|
||||||
|
</p>
|
||||||
|
<h2 id="maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||||
|
{carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="إغلاق"
|
||||||
|
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
سبب الصيانة *
|
||||||
|
<input
|
||||||
|
ref={firstRef}
|
||||||
|
style={inputStyle("reason")}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => { setReason(e.target.value); clearFieldError("reason"); }}
|
||||||
|
placeholder="تغيير زيت المحرك"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.reason && <span style={errorTextStyle}>{errors.reason}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
التكلفة (ر.س) *
|
||||||
|
<input
|
||||||
|
style={inputStyle("cost")}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={cost ?? ""}
|
||||||
|
onChange={(e) => { setCost(parseNum(e.target.value)); clearFieldError("cost"); }}
|
||||||
|
placeholder="0"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.cost && <span style={errorTextStyle}>{errors.cost}</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
تاريخ البدء *
|
||||||
|
<input
|
||||||
|
style={inputStyle("startAt")}
|
||||||
|
type="date"
|
||||||
|
value={startAt}
|
||||||
|
onChange={(e) => { setStartAt(e.target.value); clearFieldError("startAt"); }}
|
||||||
|
/>
|
||||||
|
{errors.startAt && <span style={errorTextStyle}>{errors.startAt}</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={labelStyle}>
|
||||||
|
تاريخ الانتهاء
|
||||||
|
<input
|
||||||
|
style={inputStyle("endAt")}
|
||||||
|
type="date"
|
||||||
|
value={endAt}
|
||||||
|
onChange={(e) => { setEndAt(e.target.value); clearFieldError("endAt"); }}
|
||||||
|
/>
|
||||||
|
{errors.endAt && <span style={errorTextStyle}>{errors.endAt}</span>}
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-hint)", fontWeight: 400 }}>
|
||||||
|
اتركه فارغاً إذا كانت الصيانة ما زالت جارية.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}
|
||||||
|
>
|
||||||
|
{saving && <Spinner size="sm" className="text-white" />}
|
||||||
|
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة السجل" : "حفظ التغييرات"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
src/Components/Car_Maintanance/index.ts
Normal file
0
src/Components/Car_Maintanance/index.ts
Normal file
@@ -22,6 +22,14 @@ function fmtDate(iso?: string | null): string {
|
|||||||
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" });
|
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function fmtAmount(n?: string | number | null): string {
|
||||||
|
if (n == null) return "—";
|
||||||
|
const num = typeof n === "string" ? parseFloat(n) : n;
|
||||||
|
if (Number.isNaN(num)) return "—";
|
||||||
|
return `${num.toFixed(2)} ر.س`;
|
||||||
|
}
|
||||||
|
|
||||||
export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetailModalProps) {
|
export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetailModalProps) {
|
||||||
const s = ORDER_STATUS_MAP[order.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
const s = ORDER_STATUS_MAP[order.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
||||||
|
|
||||||
@@ -53,7 +61,7 @@ export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetail
|
|||||||
<DetailRow label="اسم المستلم" value={order.recipientName} />
|
<DetailRow label="اسم المستلم" value={order.recipientName} />
|
||||||
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
|
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
|
||||||
<DetailRow label="الكمية" value={String(order.quantity)} />
|
<DetailRow label="الكمية" value={String(order.quantity)} />
|
||||||
<DetailRow label="الإجمالي الفرعي" value={order.subTotal != null ? `${order.subTotal.toFixed(2)} ر.س` : "—"} mono />
|
<DetailRow label="الإجمالي الفرعي" value={fmtAmount(order.subTotal)} mono />
|
||||||
<DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
|
<DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
|
||||||
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
|
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { Spinner } from "../../UI";
|
|||||||
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
|
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
|
||||||
import type { ArchivedOrder } from "@/src/types/order";
|
import type { ArchivedOrder } from "@/src/types/order";
|
||||||
|
|
||||||
function fmtAmount(n?: number | null): string {
|
|
||||||
|
function fmtAmount(n?: string | number | null): string {
|
||||||
if (n == null) return "—";
|
if (n == null) return "—";
|
||||||
return `${n.toFixed(2)} ر.س`;
|
const num = typeof n === "string" ? parseFloat(n) : n;
|
||||||
|
if (Number.isNaN(num)) return "—";
|
||||||
|
return `${num.toFixed(2)} ر.س`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
|
|||||||
159
src/hooks/UseCarsMaintanance.ts
Normal file
159
src/hooks/UseCarsMaintanance.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
import { carMaintenanceService } from "@/src/services/carMaintanance.service";
|
||||||
|
import type {
|
||||||
|
CarMaintenance,
|
||||||
|
CreateMaintenancePayload,
|
||||||
|
MaintenanceToastMsg,
|
||||||
|
UpdateMaintenancePayload,
|
||||||
|
} from "../types/carMaintanance";
|
||||||
|
|
||||||
|
// ── useCarMaintenanceList ─────────────────────────────────────────────────────
|
||||||
|
// Loads every maintenance record for one car — used in the detail panel.
|
||||||
|
|
||||||
|
export function useCarMaintenanceList(carId: string | null) {
|
||||||
|
const [records, setRecords] = useState<CarMaintenance[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadRecords = useCallback(() => {
|
||||||
|
if (!carId) return;
|
||||||
|
const token = getStoredToken();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
carMaintenanceService
|
||||||
|
.getAll(carId, token)
|
||||||
|
.then((res) => {
|
||||||
|
const payload = (res as unknown as { data: { data: CarMaintenance[] } }).data ?? res;
|
||||||
|
setRecords((payload as { data: CarMaintenance[] }).data ?? []);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [carId]);
|
||||||
|
|
||||||
|
useEffect(() => { loadRecords(); }, [loadRecords]);
|
||||||
|
|
||||||
|
// Remove a record from the list right away, instead of waiting on a refetch.
|
||||||
|
const removeRecord = useCallback((id: string) => {
|
||||||
|
setRecords((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { records, loading, error, loadRecords, removeRecord, setError };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── useCarMaintenanceDetail ───────────────────────────────────────────────────
|
||||||
|
// Fetches a single maintenance record by id.
|
||||||
|
|
||||||
|
export function useCarMaintenanceDetail(carId: string | null, maintenanceId: string | null) {
|
||||||
|
const [record, setRecord] = useState<CarMaintenance | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!carId || !maintenanceId) return;
|
||||||
|
const token = getStoredToken();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
carMaintenanceService
|
||||||
|
.getById(carId, maintenanceId, token)
|
||||||
|
.then((res) => setRecord((res as unknown as { data: CarMaintenance }).data))
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [carId, maintenanceId]);
|
||||||
|
|
||||||
|
return { record, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── useCarMaintenanceMutations ────────────────────────────────────────────────
|
||||||
|
// Create, update, and soft-delete operations for maintenance records.
|
||||||
|
|
||||||
|
interface UseCarMaintenanceMutationsOptions {
|
||||||
|
carId: string;
|
||||||
|
onSuccess: (msg: string) => void;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
onDeleted: (id: string) => void;
|
||||||
|
getEditTarget: () => CarMaintenance | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCarMaintenanceMutations({
|
||||||
|
carId,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
onDeleted,
|
||||||
|
getEditTarget,
|
||||||
|
}: UseCarMaintenanceMutationsOptions) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
async (
|
||||||
|
payload: CreateMaintenancePayload | UpdateMaintenancePayload,
|
||||||
|
isNew: boolean,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const token = getStoredToken();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await carMaintenanceService.create(carId, payload as CreateMaintenancePayload, token);
|
||||||
|
onSuccess("تم إضافة سجل الصيانة بنجاح.");
|
||||||
|
} else {
|
||||||
|
const editTarget = getEditTarget();
|
||||||
|
if (!editTarget) return false;
|
||||||
|
await carMaintenanceService.update(
|
||||||
|
carId,
|
||||||
|
editTarget.id,
|
||||||
|
payload as UpdateMaintenancePayload,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
onSuccess("تم تحديث سجل الصيانة.");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Show the backend's own message when it has one, otherwise a generic fallback.
|
||||||
|
onError(err instanceof Error ? err.message : "فشلت العملية.");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[carId, onSuccess, onError, getEditTarget],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteConfirm = useCallback(
|
||||||
|
async (target: CarMaintenance) => {
|
||||||
|
setDeleting(true);
|
||||||
|
const token = getStoredToken();
|
||||||
|
try {
|
||||||
|
await carMaintenanceService.delete(carId, target.id, token);
|
||||||
|
onDeleted(target.id);
|
||||||
|
onSuccess("تم حذف سجل الصيانة بنجاح.");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
onError(err instanceof Error ? err.message : "فشل الحذف.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[carId, onSuccess, onError, onDeleted],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { saving, deleting, handleFormSubmit, handleDeleteConfirm };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── useMaintenanceToast ────────────────────────────────────────────────────────
|
||||||
|
// Simple toast notification state, same shape as useCars' useToast.
|
||||||
|
|
||||||
|
export function useMaintenanceToast(duration = 3500) {
|
||||||
|
const [toast, setToast] = useState<MaintenanceToastMsg | null>(null);
|
||||||
|
|
||||||
|
const notify = useCallback(
|
||||||
|
(t: MaintenanceToastMsg) => {
|
||||||
|
setToast(t);
|
||||||
|
setTimeout(() => setToast(null), duration);
|
||||||
|
},
|
||||||
|
[duration],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { toast, notify };
|
||||||
|
}
|
||||||
0
src/hooks/archive/UseArchivedCarsMaintanance.ts
Normal file
0
src/hooks/archive/UseArchivedCarsMaintanance.ts
Normal file
0
src/hooks/archive/useArchiveBranch.ts
Normal file
0
src/hooks/archive/useArchiveBranch.ts
Normal file
0
src/hooks/archive/useArchiveClient.ts
Normal file
0
src/hooks/archive/useArchiveClient.ts
Normal file
0
src/hooks/archive/useArchiveClientAdresses.ts
Normal file
0
src/hooks/archive/useArchiveClientAdresses.ts
Normal file
0
src/hooks/archive/useArchiveRole.ts
Normal file
0
src/hooks/archive/useArchiveRole.ts
Normal file
@@ -1,58 +1,112 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { archivedOrderService } from "@/src/services/archive/archivedOrder.service";
|
import { archivedOrderService } from "@/src/services/archive/archivedOrder.service";
|
||||||
import type { ArchivedOrder } from "@/src/types/order";
|
import type { ArchivedOrder } from "@/src/types/order";
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// ── Table state / reducer ──────────────────────────────────────────────────
|
||||||
|
// نفس شكل TableState/TableAction اللي في useOrder.ts عشان الهوكين يفضلوا متسقين
|
||||||
|
interface TableState {
|
||||||
|
orders: ArchivedOrder[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableAction =
|
||||||
|
| { type: "LOAD_START" }
|
||||||
|
| { type: "LOAD_OK"; orders: ArchivedOrder[] }
|
||||||
|
| { type: "LOAD_ERR"; error: string }
|
||||||
|
| { type: "CLEAR_ERR" };
|
||||||
|
|
||||||
|
function reducer(s: TableState, a: TableAction): TableState {
|
||||||
|
switch (a.type) {
|
||||||
|
case "LOAD_START":
|
||||||
|
return { ...s, loading: true, error: null };
|
||||||
|
case "LOAD_OK":
|
||||||
|
return { ...s, loading: false, orders: a.orders };
|
||||||
|
case "LOAD_ERR":
|
||||||
|
return { ...s, loading: false, error: a.error };
|
||||||
|
case "CLEAR_ERR":
|
||||||
|
return { ...s, error: null };
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TableState = {
|
||||||
|
orders: [],
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main hook ──────────────────────────────────────────────────────────────
|
||||||
export function useArchivedOrders() {
|
export function useArchivedOrders() {
|
||||||
const [orders, setOrders] = useState<ArchivedOrder[]>([]);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
|
// ── Fetch archived orders ─────────────────────────────────────────────
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
dispatch({ type: "LOAD_START" });
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await archivedOrderService.getAll(token);
|
const res = await archivedOrderService.getAll(token);
|
||||||
setOrders(res.data);
|
|
||||||
setError(null);
|
// نفس الـ pattern بتاع useOrder.ts:
|
||||||
|
// get() بترجع axios response كامل، فـ res.data هو الـ JSON body
|
||||||
|
// {success, message, responseAt, data}. الـ "?? res" احتياطي لو
|
||||||
|
// الـ interceptor بتاع axios بيرجّع الـ body مباشرة من غير .data
|
||||||
|
const payload =
|
||||||
|
(res as unknown as { data: { data: ArchivedOrder[] } }).data ?? res;
|
||||||
|
|
||||||
|
dispatch({ type: "LOAD_OK", orders: payload.data ?? [] });
|
||||||
} catch {
|
} catch {
|
||||||
setError("تعذّر تحميل الطلبات المؤرشفة. يرجى المحاولة لاحقاً.");
|
dispatch({
|
||||||
} finally {
|
type: "LOAD_ERR",
|
||||||
setLoading(false);
|
error: "تعذّر تحميل الطلبات المؤرشفة. يرجى المحاولة لاحقاً.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
// Client-side search — endpoint has no `?search=` param in the sample payload
|
// ── Client-side search — الـ endpoint معندوش ?search= param ────────────
|
||||||
const filtered = useMemo(() => {
|
const filtered = !search.trim()
|
||||||
if (!search.trim()) return orders;
|
? state.orders
|
||||||
const q = search.trim().toLowerCase();
|
: state.orders.filter((o) => {
|
||||||
return orders.filter(
|
const q = search.trim().toLowerCase();
|
||||||
o =>
|
return (
|
||||||
o.shipmentNumber.toLowerCase().includes(q) ||
|
o.shipmentNumber.toLowerCase().includes(q) ||
|
||||||
o.recipientName.toLowerCase().includes(q) ||
|
o.recipientName.toLowerCase().includes(q) ||
|
||||||
o.recipientPhone.includes(q),
|
o.recipientPhone.includes(q)
|
||||||
);
|
);
|
||||||
}, [orders, search]);
|
});
|
||||||
|
|
||||||
const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
// ── Search / page helpers ──────────────────────────────────────────────
|
||||||
|
const handleSearch = useCallback((value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orders: paginated,
|
orders: paginated,
|
||||||
total: filtered.length,
|
total: filtered.length,
|
||||||
loading, pages, page, search, error,
|
loading: state.loading,
|
||||||
setPage, handleSearch, clearError: () => setError(null),
|
pages,
|
||||||
|
page,
|
||||||
|
search,
|
||||||
|
error: state.error,
|
||||||
|
setPage,
|
||||||
|
handleSearch,
|
||||||
|
clearError: () => dispatch({ type: "CLEAR_ERR" }),
|
||||||
refresh: load,
|
refresh: load,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
0
src/services/archive/archivedBranch.service.ts
Normal file
0
src/services/archive/archivedBranch.service.ts
Normal file
0
src/services/archive/archivedClient.service.ts
Normal file
0
src/services/archive/archivedClient.service.ts
Normal file
0
src/services/archive/archivedRole.service.ts
Normal file
0
src/services/archive/archivedRole.service.ts
Normal file
80
src/services/carMaintanance.service.ts
Normal file
80
src/services/carMaintanance.service.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { get, post, patch, del } from "./api";
|
||||||
|
import type {
|
||||||
|
CarMaintenance,
|
||||||
|
MaintenanceListResponse,
|
||||||
|
MaintenanceDetailResponse,
|
||||||
|
CreateMaintenancePayload,
|
||||||
|
UpdateMaintenancePayload,
|
||||||
|
} from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
|
/** Build a query string for list endpoints — skips empty values. */
|
||||||
|
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
|
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
||||||
|
if (!entries.length) return "";
|
||||||
|
return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const carMaintenanceService = {
|
||||||
|
/**
|
||||||
|
* GET /cars/:carId/maintenance
|
||||||
|
* Fetch every maintenance record that belongs to one car.
|
||||||
|
*
|
||||||
|
* Example response:
|
||||||
|
* { "data": { "data": [ { "id": "...", "reason": "تغيير زيت", "cost": 150, ... } ] } }
|
||||||
|
*/
|
||||||
|
getAll: (carId: string, token: string | null) =>
|
||||||
|
get<MaintenanceListResponse>(`cars/${carId}/maintenance`, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cars/:carId/maintenance/:maintenanceId
|
||||||
|
* Fetch a single maintenance record with full details.
|
||||||
|
*/
|
||||||
|
getById: (carId: string, maintenanceId: string, token: string | null) =>
|
||||||
|
get<MaintenanceDetailResponse>(`cars/${carId}/maintenance/${maintenanceId}`, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /cars/:carId/maintenance
|
||||||
|
* Create a new maintenance record for a car.
|
||||||
|
*
|
||||||
|
* Example request body:
|
||||||
|
* { "reason": "تغيير زيت", "cost": 150, "startAt": "2026-01-10T00:00:00.000Z" }
|
||||||
|
*/
|
||||||
|
create: (carId: string, payload: CreateMaintenancePayload, token: string | null) =>
|
||||||
|
post<{ data: CarMaintenance }>(`cars/${carId}/maintenance`, payload, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /cars/:carId/maintenance/:maintenanceId
|
||||||
|
* Update an existing maintenance record (for example, closing it out
|
||||||
|
* by setting an endAt date, or correcting the cost).
|
||||||
|
*/
|
||||||
|
update: (
|
||||||
|
carId: string,
|
||||||
|
maintenanceId: string,
|
||||||
|
payload: UpdateMaintenancePayload,
|
||||||
|
token: string | null,
|
||||||
|
) =>
|
||||||
|
patch<{ data: CarMaintenance }>(
|
||||||
|
`cars/${carId}/maintenance/${maintenanceId}`,
|
||||||
|
payload,
|
||||||
|
token,
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /cars/:carId/maintenance/:maintenanceId
|
||||||
|
* Soft-delete a maintenance record (returns 204 No Content).
|
||||||
|
*/
|
||||||
|
delete: (carId: string, maintenanceId: string, token: string | null) =>
|
||||||
|
del<void>(`cars/${carId}/maintenance/${maintenanceId}`, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cars/:carId/maintenance/archived
|
||||||
|
* Fetch soft-deleted maintenance records for a car — mirrors the car
|
||||||
|
* module's archive pattern in case the backend exposes the same route.
|
||||||
|
*/
|
||||||
|
getArchived: (carId: string, token: string | null) =>
|
||||||
|
get<MaintenanceListResponse>(`cars/${carId}/maintenance/archived`, token),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-exported so callers can build extra filters (day/month/year, search…)
|
||||||
|
// the same way carService does, without duplicating the helper.
|
||||||
|
export { buildQuery as buildMaintenanceQuery };
|
||||||
119
src/types/carMaintanance.ts
Normal file
119
src/types/carMaintanance.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// All TypeScript interfaces for the Car Maintenance module.
|
||||||
|
// This file follows the same pattern as src/types/car.ts so the two
|
||||||
|
// modules feel consistent to work with.
|
||||||
|
|
||||||
|
// ── Status helpers ───────────────────────────────────────────────────────────
|
||||||
|
// A maintenance record is "ongoing" while it has no end date yet, and
|
||||||
|
// "completed" once endAt is filled in. This is worked out on the frontend —
|
||||||
|
// the backend does not send a status field.
|
||||||
|
export type MaintenanceStatus = "Ongoing" | "Completed";
|
||||||
|
|
||||||
|
export const MAINTENANCE_STATUS_MAP: Record<
|
||||||
|
MaintenanceStatus,
|
||||||
|
{ label: string; color: string; bg: string; border: string; dot: string }
|
||||||
|
> = {
|
||||||
|
Ongoing: { label: "جارية", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
||||||
|
Completed: { label: "منتهية", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Works out whether a record is still open or already finished. */
|
||||||
|
export function getMaintenanceStatus(record: Pick<CarMaintenance, "endAt">): MaintenanceStatus {
|
||||||
|
return record.endAt ? "Completed" : "Ongoing";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared date + money helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function fmtDate(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
|
year: "numeric", month: "long", day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDateShort(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
|
year: "numeric", month: "short", day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtCost(cost?: number | null): string {
|
||||||
|
if (cost === undefined || cost === null) return "—";
|
||||||
|
return `${cost.toLocaleString("ar-SA")} ر.س`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many days a maintenance job has taken (or is still taking). */
|
||||||
|
export function durationDays(startAt: string, endAt?: string | null): number {
|
||||||
|
const start = new Date(startAt).getTime();
|
||||||
|
const end = endAt ? new Date(endAt).getTime() : Date.now();
|
||||||
|
return Math.max(0, Math.round((end - start) / 86_400_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core Maintenance record ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CarMaintenance {
|
||||||
|
id: string;
|
||||||
|
carId: string;
|
||||||
|
|
||||||
|
reason: string;
|
||||||
|
cost: number;
|
||||||
|
startAt: string;
|
||||||
|
endAt?: string | null;
|
||||||
|
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
|
||||||
|
// Optional expanded relation — present when the record is fetched
|
||||||
|
// together with its parent car (e.g. from a "for this car" list).
|
||||||
|
car?: {
|
||||||
|
id: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
plateNumber: string;
|
||||||
|
plateLetters: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payloads ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateMaintenancePayload {
|
||||||
|
reason: string;
|
||||||
|
cost: number;
|
||||||
|
startAt: string;
|
||||||
|
endAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateMaintenancePayload = Partial<CreateMaintenancePayload> & {
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── API Responses ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MaintenanceListResponse {
|
||||||
|
data: {
|
||||||
|
data: CarMaintenance[];
|
||||||
|
pagination?: { total: number; page: number; pages: number };
|
||||||
|
meta?: { total: number; pages: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaintenanceDetailResponse {
|
||||||
|
data: CarMaintenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MaintenanceFormErrors {
|
||||||
|
reason?: string;
|
||||||
|
cost?: string;
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MaintenanceToastMsg {
|
||||||
|
type: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ export interface ArchivedOrder {
|
|||||||
deliveryAddressId?: string | null;
|
deliveryAddressId?: string | null;
|
||||||
currentStatus: OrderStatus;
|
currentStatus: OrderStatus;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
subTotal?: number | null;
|
subTotal?: string | number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
statusHistory?: Array<{
|
statusHistory?: Array<{
|
||||||
|
|||||||
75
src/validations/carMaintanance.validator.ts
Normal file
75
src/validations/carMaintanance.validator.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
// ── Shared date helper ────────────────────────────────────────────────────────
|
||||||
|
// <input type="date"> gives back "YYYY-MM-DD" — just check it parses as a real date.
|
||||||
|
const dateString = yup
|
||||||
|
.string()
|
||||||
|
.test("valid-date", "تاريخ غير صالح", (val) => {
|
||||||
|
if (!val) return true; // optional, unless required elsewhere
|
||||||
|
return !isNaN(Date.parse(val));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create schema ─────────────────────────────────────────────────────────────
|
||||||
|
// Used when adding a brand-new maintenance record for a car.
|
||||||
|
|
||||||
|
export const createMaintenanceSchema = yup.object({
|
||||||
|
reason: yup
|
||||||
|
.string()
|
||||||
|
.required("سبب الصيانة مطلوب")
|
||||||
|
.min(3, "سبب الصيانة يجب أن يكون 3 أحرف على الأقل"),
|
||||||
|
|
||||||
|
cost: yup
|
||||||
|
.number()
|
||||||
|
.typeError("التكلفة يجب أن تكون رقماً")
|
||||||
|
.required("التكلفة مطلوبة")
|
||||||
|
.min(0, "التكلفة يجب أن تكون 0 أو أكثر"),
|
||||||
|
|
||||||
|
startAt: dateString.required("تاريخ البدء مطلوب"),
|
||||||
|
|
||||||
|
// endAt is optional (the job may still be ongoing), but if it is given
|
||||||
|
// it must not be earlier than the start date.
|
||||||
|
endAt: dateString.test(
|
||||||
|
"after-start",
|
||||||
|
"تاريخ الانتهاء يجب أن يكون بعد تاريخ البدء",
|
||||||
|
function (val) {
|
||||||
|
const { startAt } = this.parent as { startAt?: string };
|
||||||
|
if (!val || !startAt) return true;
|
||||||
|
return new Date(val).getTime() >= new Date(startAt).getTime();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update schema (everything optional, but same rules where present) ─────────
|
||||||
|
|
||||||
|
export const updateMaintenanceSchema = yup.object({
|
||||||
|
reason: yup
|
||||||
|
.string()
|
||||||
|
.min(3, "سبب الصيانة يجب أن يكون 3 أحرف على الأقل")
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
cost: yup
|
||||||
|
.number()
|
||||||
|
.typeError("التكلفة يجب أن تكون رقماً")
|
||||||
|
.min(0, "التكلفة يجب أن تكون 0 أو أكثر")
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
startAt: dateString.optional(),
|
||||||
|
|
||||||
|
endAt: dateString.test(
|
||||||
|
"after-start",
|
||||||
|
"تاريخ الانتهاء يجب أن يكون بعد تاريخ البدء",
|
||||||
|
function (val) {
|
||||||
|
const { startAt } = this.parent as { startAt?: string };
|
||||||
|
if (!val || !startAt) return true;
|
||||||
|
return new Date(val).getTime() >= new Date(startAt).getTime();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
isActive: yup.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Infer form error shape ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MaintenanceSchemaErrors = Partial<
|
||||||
|
Record<"reason" | "cost" | "startAt" | "endAt" | "isActive", string>
|
||||||
|
>;
|
||||||
Reference in New Issue
Block a user