first action update user pages .

2-update driver pages [fixed image display and notyfi]
3-update car pages [fixed image]
4-build tripe pages crud
5-build some role pages
6-build audit page
7-update ui compounants
8-update saidebar and topbar
9-cheange view to be ar view
10-cheange stractcher to be app,src
11-add validation layer by yup
12-add api image-proxy to broke image corse bloken
This commit is contained in:
m7amedez5511
2026-06-23 15:54:07 +03:00
parent c1b77f89cd
commit db64b79fe3
140 changed files with 9266 additions and 2449 deletions

View File

@@ -1,497 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as yup from "yup";
import { Alert, Spinner } from "../UI";
import { get } from "@/services/api";
import { getStoredToken } from "@/lib/auth";
import { createTripSchema, updateTripSchema } from "@/validations/trip.validator";
import type { TripSchemaErrors } from "@/validations/trip.validator";
import type {
Trip,
TripStatus,
CreateTripPayload,
UpdateTripPayload,
} from "@/types/trip";
// ── Shared styles ────────────────────────────────────────────────────────────
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,
};
const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: "0.5rem 0 0",
};
const optionalLabelStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
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 ────────────────────────────────────────────────────────────────────
interface TripFormModalProps {
editTrip: Trip | null;
onClose: () => void;
onSubmit: (
payload: CreateTripPayload | UpdateTripPayload,
isNew: boolean,
) => Promise<boolean>;
}
// ── Component ────────────────────────────────────────────────────────────────
export function TripFormModal({ editTrip, onClose, onSubmit }: TripFormModalProps) {
const isNew = editTrip === null;
// ── Dropdown options ──────────────────────────────────────────────────────
const [drivers, setDrivers] = useState<DriverOption[]>([]);
const [cars, setCars] = useState<CarOption[]>([]);
const [branches, setBranches] = useState<BranchOption[]>([]);
useEffect(() => {
const token = getStoredToken();
// Fetch active drivers
get<{ data: { data: DriverOption[] } }>("driver?limit=100&status=Active", token)
.then(res => setDrivers((res as unknown as { data: { data: DriverOption[] } }).data?.data ?? []))
.catch(() => {});
// Fetch active cars
get<{ data: { data: CarOption[] } }>("cars?limit=100&status=Active", token)
.then(res => setCars((res as unknown as { data: { data: CarOption[] } }).data?.data ?? []))
.catch(() => {});
// Fetch branches
get<{ data: { data: BranchOption[] } }>("branches?limit=100", token)
.then(res => setBranches((res as unknown as { data: { data: BranchOption[] } }).data?.data ?? []))
.catch(() => {});
}, []);
// ── Form state ────────────────────────────────────────────────────────────
const [title, setTitle] = useState(editTrip?.title ?? "");
const [driverId, setDriverId] = useState(editTrip?.driverId ?? "");
const [carId, setCarId] = useState(editTrip?.carId ?? "");
const [branchId, setBranchId] = useState(editTrip?.branchId ?? "");
const [status, setStatus] = useState<TripStatus>(editTrip?.status ?? "Scheduled");
const [startTime, setStartTime] = useState(editTrip?.startTime?.slice(0, 16) ?? "");
const [endTime, setEndTime] = useState(editTrip?.endTime?.slice(0, 16) ?? "");
const [collectedCount, setCollectedCount] = useState<string>(
editTrip?.collectedCount != null ? String(editTrip.collectedCount) : "",
);
const [deliveredCount, setDeliveredCount] = useState<string>(
editTrip?.deliveredCount != null ? String(editTrip.deliveredCount) : "",
);
const [returnedCount, setReturnedCount] = useState<string>(
editTrip?.returnedCount != null ? String(editTrip.returnedCount) : "",
);
const [totalCashCollected, setTotalCashCollected] = useState<string>(
editTrip?.totalCashCollected != null ? String(editTrip.totalCashCollected) : "",
);
const [notes, setNotes] = useState(editTrip?.notes ?? "");
const [endReason, setEndReason] = useState(editTrip?.endReason ?? "");
const [reason, setReason] = useState(""); // update-only
const [errors, setErrors] = useState<TripSchemaErrors>({});
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]);
// ── Error style helper ────────────────────────────────────────────────────
const inputStyle = (field: keyof TripSchemaErrors): React.CSSProperties => ({
...inputBase,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
const clearErr = (field: keyof TripSchemaErrors) =>
setErrors(p => ({ ...p, [field]: undefined }));
// ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const raw: Record<string, unknown> = {
title: title || undefined,
driverId: driverId || undefined,
carId: carId || undefined,
branchId: branchId || undefined,
status: status || undefined,
startTime: startTime ? `${startTime}:00.000Z` : undefined,
endTime: endTime ? `${endTime}:00.000Z` : undefined,
collectedCount: collectedCount !== "" ? Number(collectedCount) : undefined,
deliveredCount: deliveredCount !== "" ? Number(deliveredCount) : undefined,
returnedCount: returnedCount !== "" ? Number(returnedCount) : undefined,
totalCashCollected: totalCashCollected !== "" ? Number(totalCashCollected) : undefined,
notes: notes || undefined,
endReason: endReason || undefined,
...(!isNew && reason ? { reason } : {}),
};
try {
const schema = isNew ? createTripSchema : updateTripSchema;
await schema.validate(raw, { abortEarly: false });
} catch (err) {
if (err instanceof yup.ValidationError) {
const map: TripSchemaErrors = {};
err.inner.forEach(e => {
if (e.path) (map as Record<string, string>)[e.path] = e.message;
});
setErrors(map);
}
return;
}
setSaving(true);
setApiError("");
try {
const ok = await onSubmit(raw as CreateTripPayload | UpdateTripPayload, isNew);
if (ok) onClose();
else setApiError("حدث خطأ أثناء الحفظ. يرجى المحاولة مرة أخرى.");
} catch (err) {
setApiError(err instanceof Error ? err.message : "حدث خطأ غير متوقع.");
} finally {
setSaving(false);
}
};
return (
<div
role="dialog" aria-modal="true" aria-labelledby="trip-form-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 60,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "2rem 1rem", overflowY: "auto",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 700,
background: "var(--color-surface)",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 56px rgba(0,0,0,.2)",
overflow: "hidden",
}}
>
{/* Header */}
<div style={{
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<h2 id="trip-form-title" style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
</h2>
<button type="button" onClick={onClose}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--color-text-muted)", padding: 4 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{apiError && (
<Alert variant="error">{apiError}</Alert>
)}
{/* ── Section: أساسيات الرحلة ── */}
<p style={sectionHeadingStyle}>أساسيات الرحلة</p>
{/* Title */}
<label style={labelStyle}>
عنوان الرحلة <span style={{ color: "var(--color-danger)" }}>*</span>
<input
ref={firstRef}
style={inputStyle("title")}
value={title}
onChange={e => { setTitle(e.target.value); clearErr("title"); }}
placeholder="مثال: توزيع الرياض الشمالي"
dir="rtl"
/>
{errors.title && <span style={errorTextStyle}>{errors.title}</span>}
</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{/* Driver */}
<label style={labelStyle}>
السائق <span style={{ color: "var(--color-danger)" }}>*</span>
<select
style={{ ...inputStyle("driverId"), cursor: "pointer" }}
value={driverId}
onChange={e => { setDriverId(e.target.value); clearErr("driverId"); }}
dir="rtl"
>
<option value="">اختر السائق</option>
{drivers.map(d => (
<option key={d.id} value={d.id}>{d.name} {d.phone}</option>
))}
</select>
{errors.driverId && <span style={errorTextStyle}>{errors.driverId}</span>}
</label>
{/* Car */}
<label style={labelStyle}>
السيارة <span style={{ color: "var(--color-danger)" }}>*</span>
<select
style={{ ...inputStyle("carId"), cursor: "pointer" }}
value={carId}
onChange={e => { setCarId(e.target.value); clearErr("carId"); }}
dir="rtl"
>
<option value="">اختر السيارة</option>
{cars.map(c => (
<option key={c.id} value={c.id}>{c.manufacturer} {c.model} {c.plateNumber}</option>
))}
</select>
{errors.carId && <span style={errorTextStyle}>{errors.carId}</span>}
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{/* Branch */}
<label style={labelStyle}>
الفرع <span style={optionalLabelStyle}>(اختياري)</span>
<select
style={{ ...inputStyle("branchId"), cursor: "pointer" }}
value={branchId}
onChange={e => { setBranchId(e.target.value); clearErr("branchId"); }}
dir="rtl"
>
<option value="">اختر الفرع</option>
{branches.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
{errors.branchId && <span style={errorTextStyle}>{errors.branchId}</span>}
</label>
{/* Status */}
<label style={labelStyle}>
الحالة
<select
style={{ ...inputBase, cursor: "pointer" }}
value={status}
onChange={e => setStatus(e.target.value as TripStatus)}
dir="rtl"
>
<option value="Scheduled">مجدولة</option>
<option value="InProgress">جارية</option>
<option value="Completed">مكتملة</option>
<option value="Cancelled">ملغاة</option>
</select>
</label>
</div>
{/* ── Section: التوقيت ── */}
<p style={sectionHeadingStyle}>التوقيت</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
وقت البدء <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="datetime-local"
style={inputStyle("startTime")}
value={startTime}
onChange={e => { setStartTime(e.target.value); clearErr("startTime"); }}
/>
{errors.startTime && <span style={errorTextStyle}>{errors.startTime}</span>}
</label>
<label style={labelStyle}>
وقت الانتهاء <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="datetime-local"
style={inputStyle("endTime")}
value={endTime}
onChange={e => { setEndTime(e.target.value); clearErr("endTime"); }}
/>
{errors.endTime && <span style={errorTextStyle}>{errors.endTime}</span>}
</label>
</div>
{/* ── Section: الأعداد والمبالغ ── */}
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
المجمّع <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number" min="0"
style={inputStyle("collectedCount")}
value={collectedCount}
onChange={e => { setCollectedCount(e.target.value); clearErr("collectedCount"); }}
placeholder="0"
dir="ltr"
/>
{errors.collectedCount && <span style={errorTextStyle}>{errors.collectedCount}</span>}
</label>
<label style={labelStyle}>
المُسلَّم <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number" min="0"
style={inputStyle("deliveredCount")}
value={deliveredCount}
onChange={e => { setDeliveredCount(e.target.value); clearErr("deliveredCount"); }}
placeholder="0"
dir="ltr"
/>
{errors.deliveredCount && <span style={errorTextStyle}>{errors.deliveredCount}</span>}
</label>
<label style={labelStyle}>
المُرتجع <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number" min="0"
style={inputStyle("returnedCount")}
value={returnedCount}
onChange={e => { setReturnedCount(e.target.value); clearErr("returnedCount"); }}
placeholder="0"
dir="ltr"
/>
{errors.returnedCount && <span style={errorTextStyle}>{errors.returnedCount}</span>}
</label>
<label style={labelStyle}>
النقد المحصّل <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number" min="0" step="0.01"
style={inputStyle("totalCashCollected")}
value={totalCashCollected}
onChange={e => { setTotalCashCollected(e.target.value); clearErr("totalCashCollected"); }}
placeholder="0.00"
dir="ltr"
/>
{errors.totalCashCollected && <span style={errorTextStyle}>{errors.totalCashCollected}</span>}
</label>
</div>
{/* ── Section: ملاحظات ── */}
<p style={sectionHeadingStyle}>ملاحظات</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={labelStyle}>
ملاحظات <span style={optionalLabelStyle}>(اختياري)</span>
<textarea
style={{ ...inputBase, height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }}
value={notes}
onChange={e => { setNotes(e.target.value); clearErr("notes"); }}
placeholder="أي ملاحظات إضافية…"
dir="rtl"
/>
{errors.notes && <span style={errorTextStyle}>{errors.notes}</span>}
</label>
<label style={labelStyle}>
سبب الإنهاء <span style={optionalLabelStyle}>(اختياري)</span>
<textarea
style={{ ...inputBase, height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }}
value={endReason}
onChange={e => { setEndReason(e.target.value); clearErr("endReason"); }}
placeholder="سبب إنهاء أو إلغاء الرحلة…"
dir="rtl"
/>
{errors.endReason && <span style={errorTextStyle}>{errors.endReason}</span>}
</label>
</div>
{/* reason — edit only (for reassigning driver/car) */}
{!isNew && (
<>
<p style={sectionHeadingStyle}>سجل التغيير</p>
<label style={labelStyle}>
سبب التعديل <span style={optionalLabelStyle}>(مطلوب عند تغيير السائق أو السيارة)</span>
<input
style={inputBase}
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
dir="rtl"
/>
</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>
);
}

View File

@@ -1,97 +0,0 @@
"use client";
import { useState } from "react";
import { Spinner } from "../UI";
import { tripService } from "../../services/trip.service";
import { getStoredToken } from "../../lib/auth";
interface TripReportPanelProps {
tripId: string;
}
export function TripReportPanel({ tripId }: TripReportPanelProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleGenerate = async () => {
setLoading(true);
setError(null);
try {
const token = getStoredToken();
const res = await tripService.getReport(tripId, token);
const reportUrl = (res as unknown as { data: { reportUrl: string } }).data?.reportUrl;
if (!reportUrl) throw new Error("لم يتم إرجاع رابط التقرير.");
window.open(reportUrl, "_blank", "noopener,noreferrer");
} catch (err) {
setError(err instanceof Error ? err.message : "تعذّر إنشاء التقرير.");
} finally {
setLoading(false);
}
};
return (
<div
style={{
marginTop: "1.5rem",
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "1rem",
}}
>
<p style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 700,
margin: "0 0 0.75rem",
}}>
إنشاء تقرير الرحلة
</p>
<button
type="button"
onClick={handleGenerate}
disabled={loading}
style={{
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "none",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
background: loading ? "var(--color-brand-400)" : "var(--color-brand-600)",
cursor: loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
whiteSpace: "nowrap",
}}
>
{loading && <Spinner size="sm" className="text-white" />}
{loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"}
</button>
{error && (
<div
style={{
marginTop: "0.75rem",
borderRadius: "var(--radius-md)",
background: "#FEF2F2",
border: "1px solid #FECACA",
padding: "0.625rem 0.875rem",
fontSize: 12,
color: "#DC2626",
fontWeight: 500,
}}
>
{error}
</div>
)}
</div>
);
}

View File

@@ -1,54 +0,0 @@
interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
/** Footer slot typically action buttons */
footer?: React.ReactNode;
size?: "sm" | "md" | "lg";
}
const MODAL_SIZES = { sm: "max-w-sm", md: "max-w-lg", lg: "max-w-2xl" };
export function Modal({ open, title, onClose, children, footer, size = "md" }: ModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className={`relative w-full ${MODAL_SIZES[size]} rounded-2xl bg-white shadow-2xl flex flex-col max-h-[90vh]`}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<h2 id="modal-title" className="text-base font-semibold text-slate-800">
{title}
</h2>
<button
onClick={onClose}
aria-label="Close"
className="text-slate-400 hover:text-slate-600 transition-colors text-xl leading-none"
>
×
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-5">{children}</div>
{/* Footer */}
{footer && (
<div className="flex justify-end gap-2 border-t border-slate-100 px-6 py-4">
{footer}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,29 +0,0 @@
interface PageHeaderProps {
title: string;
description?: string;
action?: React.ReactNode;
backHref?: string;
backLabel?: string;
}
export function PageHeader({ title, description, action, backHref, backLabel }: PageHeaderProps) {
return (
<div className="mb-6 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
{backHref && (
<a
href={backHref}
className="mb-2 inline-flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-800 font-medium"
>
{backLabel ?? "Back"}
</a>
)}
<h1 className="text-2xl font-bold tracking-tight text-slate-900">{title}</h1>
{description && (
<p className="mt-1 text-sm text-slate-500">{description}</p>
)}
</div>
{action && <div className="mt-3 sm:mt-0 shrink-0">{action}</div>}
</div>
);
}

View File

@@ -1,52 +0,0 @@
import React, { forwardRef, SelectHTMLAttributes } from "react";
// ─── Select ──────────────────────────────────────────────────────────────────
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
wrapperClassName?: string;
children: React.ReactNode;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ label, error, wrapperClassName = "", className = "", id, children, ...rest },
ref
) {
const selectId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
return (
<div className={`flex flex-col gap-1 ${wrapperClassName}`}>
{label && (
<label
htmlFor={selectId}
className="text-xs font-semibold uppercase tracking-wide text-slate-500"
>
{label}
</label>
)}
<select
ref={ref}
id={selectId}
className={`
w-full rounded-lg border bg-white px-3 py-2 text-sm text-slate-800
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
disabled:bg-slate-50 disabled:cursor-not-allowed
${error ? "border-red-400" : "border-slate-300"}
${className}
`}
{...rest}
>
{children}
</select>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
});

View File

@@ -1,41 +0,0 @@
import { forwardRef } from "react";
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
wrapperClassName?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ label, error, wrapperClassName = "", className = "", id, ...rest },
ref
) {
const taId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
return (
<div className={`flex flex-col gap-1 ${wrapperClassName}`}>
{label && (
<label
htmlFor={taId}
className="text-xs font-semibold uppercase tracking-wide text-slate-500"
>
{label}
</label>
)}
<textarea
ref={ref}
id={taId}
rows={3}
className={`
w-full rounded-lg border bg-white px-3 py-2 text-sm text-slate-800
placeholder:text-slate-400 resize-none
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
disabled:bg-slate-50 disabled:text-slate-400
${error ? "border-red-400" : "border-slate-300"}
${className}
`}
{...rest}
/>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
});

View File

@@ -1,4 +1,4 @@
// app/api/auth/clear-cookie/route.ts
// Clears the HttpOnly auth cookie on logout. // Clears the HttpOnly auth cookie on logout.
// Only the server can delete an HttpOnly cookie. // Only the server can delete an HttpOnly cookie.

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
const BACKEND_BASE = "https://logiapi.slash.sa";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ filename: string }> },
) {
const { filename } = await params;
try {
const upstream = await fetch(
`${BACKEND_BASE}/public/uploads/driver-photos/${filename}`,
{ cache: "no-store" },
);
if (!upstream.ok) {
return new NextResponse(null, { status: upstream.status });
}
const buffer = await upstream.arrayBuffer();
const contentType =
upstream.headers.get("Content-Type") ?? "image/jpeg";
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch {
return new NextResponse(null, { status: 502 });
}
}

View File

@@ -10,51 +10,54 @@ async function proxy(
const path = params.path?.join("/") ?? ""; const path = params.path?.join("/") ?? "";
const targetUrl = `${BACKEND_BASE_URL}/${path}${request.nextUrl.search}`; const targetUrl = `${BACKEND_BASE_URL}/${path}${request.nextUrl.search}`;
// Read the HttpOnly cookie server-side — this is the ONLY place the raw
// token value is accessible after the Issue 3 refactor.
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value; const token = cookieStore.get("auth_token")?.value;
const headers = new Headers(); const headers = new Headers();
// Forward all safe request headers.
request.headers.forEach((value, key) => { request.headers.forEach((value, key) => {
if (key === "host" || key === "content-length" || key === "cookie") return; if (key === "host" || key === "content-length" || key === "cookie") return;
headers.set(key, value); headers.set(key, value);
}); });
// Inject the Authorization header using the server-side cookie value.
// The client never had access to this token value.
if (token) { if (token) {
headers.set("Authorization", `Bearer ${decodeURIComponent(token)}`); headers.set("Authorization", `Bearer ${decodeURIComponent(token)}`);
} }
if (!headers.has("Content-Type")) { // NOTE: do NOT force a default Content-Type here when there's no body —
// for multipart/form-data requests, the browser sets Content-Type itself
// (including the multipart boundary). We only need a fallback for bodied
// requests that didn't already specify one (e.g. raw JSON fetches).
const isBodyless = request.method === "GET" || request.method === "HEAD";
// CRITICAL FIX: read the body as raw bytes (ArrayBuffer), never as text.
// request.text() decodes the body as UTF-8, which corrupts any binary
// payload (images inside multipart/form-data) — invalid UTF-8 byte
// sequences get silently replaced, mangling the uploaded file before it
// ever reaches the backend. ArrayBuffer passes bytes through untouched,
// and works equally well for JSON bodies.
const body = isBodyless ? undefined : await request.arrayBuffer();
if (!isBodyless && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
} }
const isBodyless = request.method === "GET" || request.method === "HEAD";
const body = isBodyless ? undefined : await request.text();
const upstream = await fetch(targetUrl, { const upstream = await fetch(targetUrl, {
method: request.method, method: request.method,
headers, headers,
body, body,
cache: "no-store", cache: "no-store",
// @ts-expect-error - duplex is required by undici when streaming a body
// but isn't yet in the official RequestInit types in this Next.js version.
duplex: body ? "half" : undefined,
}); });
const contentType = const contentType =
upstream.headers.get("content-type") || "application/json"; upstream.headers.get("content-type") || "application/json";
// Responses with these statuses MUST NOT carry a body (per the Fetch/HTTP
// spec). Constructing a NextResponse with a body alongside one of these
// statuses throws at runtime, which Next.js then surfaces as a 500 to the
// client — even though the upstream call itself succeeded. This is exactly
// what happens on DELETE endpoints that correctly return 204 No Content.
const isNoBodyStatus = [204, 205, 304].includes(upstream.status); const isNoBodyStatus = [204, 205, 304].includes(upstream.status);
if (isNoBodyStatus) { if (isNoBodyStatus) {
// Drain the upstream body (should be empty anyway) without parsing it.
await upstream.text().catch(() => null); await upstream.text().catch(() => null);
return new NextResponse(null, { return new NextResponse(null, {
status: upstream.status, status: upstream.status,
@@ -62,6 +65,19 @@ async function proxy(
}); });
} }
// Pass binary responses (e.g. images) through untouched too, just in case
// any proxied endpoint ever returns one.
if (!contentType.includes("application/json") && !contentType.includes("text")) {
const buffer = await upstream.arrayBuffer();
return new NextResponse(buffer, {
status: upstream.status,
headers: {
"content-type": contentType,
"cache-control": "no-store",
},
});
}
const responseBody = contentType.includes("application/json") const responseBody = contentType.includes("application/json")
? await upstream.json().catch(() => null) ? await upstream.json().catch(() => null)
: await upstream.text(); : await upstream.text();

View File

@@ -1,4 +1,4 @@
import Logo from "@/utils/logo"; import Logo from "@/src/utils/logo";
import Link from "next/link"; import Link from "next/link";
@@ -13,7 +13,7 @@ const navLinks = [
export default function Navbar() { export default function Navbar() {
return ( return (
<nav dir="rtl" className="w-full border-b border-slate-200 bg-white text-slate-900 shadow-sm">x <nav dir="rtl" className="w-full border-b border-slate-200 bg-white text-slate-900 shadow-sm">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-6 lg:px-8"> <div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-6 lg:px-8">
<a href="/" className="flex items-center gap-3 text-right"> <a href="/" className="flex items-center gap-3 text-right">
<Logo /> <Logo />
@@ -33,8 +33,21 @@ export default function Navbar() {
))} ))}
</div> </div>
<div className="mr-auto flex items-center gap-2"> {/*
Was `mr-auto`. `mr-*` is a PHYSICAL Tailwind utility — it means
"margin-right" no matter what `dir` says, so it would have
pinned the login button to the visual left in both LTR and
RTL, fighting the rest of the nav. `ms-auto` ("margin-inline-
start: auto") pushes against the start edge instead, which is
right in RTL and left in LTR — it tracks `dir` automatically.
This is the single most common RTL bug: Tailwind's l/r-prefixed
utilities (ml, mr, pl, pr, left-, right-, text-left, text-right,
rounded-l, rounded-r, border-l, border-r) are all physical and
need their logical (s/e) equivalents — ms, me, ps, pe, start-,
end-, text-start, text-end, rounded-s, rounded-e, border-s,
border-e — anywhere direction should matter.
*/}
<div className="ms-auto flex items-center gap-2">
<Link href="/login" className="flex h-[34px] items-center rounded-lg bg-blue-600 px-3 text-[13px] font-bold text-white shadow-sm transition hover:bg-blue-700"> <Link href="/login" className="flex h-[34px] items-center rounded-lg bg-blue-600 px-3 text-[13px] font-bold text-white shadow-sm transition hover:bg-blue-700">
تسجيل الدخول تسجيل الدخول
</Link> </Link>

View File

@@ -2,8 +2,8 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { getStoredUser } from "../../../lib/auth"; import { getStoredUser } from "@/src/lib/auth";
import Logo from "../../../utils/logo"; import Logo from "@/src/utils/logo";
const navSections = [ const navSections = [
{ {
@@ -40,39 +40,110 @@ export function Sidebar() {
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));
return ( return (
<aside <>
suppressHydrationWarning {/*
className="hidden lg:flex lg:flex-col" Full sidebar — visible lg+.
style={{ No `left`/`right` is set anywhere: placement comes purely from
width: "var(--sidebar-width)", document order + `dir="rtl"` on <html>. A flex row in RTL lays
flexShrink: 0, its first child against the inline-start edge, which is
background: "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)", physically the RIGHT of the screen. Because <Sidebar /> is
minHeight: "100vh", still the first child in DashboardLayout, it now renders on
padding: "20px 12px", the right automatically — no positional CSS to flip.
}} */}
> <aside
suppressHydrationWarning
className="hidden lg:flex lg:flex-col"
style={{
width: "var(--sidebar-width)",
flexShrink: 0,
background: "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)",
minHeight: "100vh",
padding: "20px 12px",
}}
>
<SidebarContent
pathname={pathname}
permissions={permissions}
user={user}
compact={false}
/>
</aside>
{/*
Compact icon rail — md and below (replaces the old behavior of
hiding the sidebar entirely under `lg`). Fixed to the
inline-end... no: fixed to the RIGHT edge explicitly, because a
`position: fixed` element takes no part in flex flow and so
gets no automatic RTL placement from document order. This is
exactly the kind of element logical properties exist for:
`inset-inline-end: 0` means "right in RTL, left in LTR"
without a manual swap if direction ever toggles.
*/}
<aside
suppressHydrationWarning
className="hidden md:flex lg:hidden md:flex-col md:items-center"
style={{
position: "fixed",
insetBlockStart: 0,
insetBlockEnd: 0,
insetInlineEnd: 0,
width: "var(--sidebar-width-compact)",
background: "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)",
padding: "16px 0",
zIndex: 30,
overflowY: "auto",
}}
aria-label="التنقل المصغر"
>
<SidebarContent
pathname={pathname}
permissions={permissions}
user={user}
compact={true}
/>
</aside>
</>
);
}
function SidebarContent({
pathname,
permissions,
user,
compact,
}: {
pathname: string;
permissions: string[];
user: ReturnType<typeof getStoredUser>;
compact: boolean;
}) {
return (
<>
{/* Logo */} {/* Logo */}
<div style={{ marginBottom: 28 }}> <div style={{ marginBottom: 28, display: "flex", justifyContent: compact ? "center" : "flex-start" }}>
<Logo white /> <Logo white />
</div> </div>
{/* Nav sections */} {/* Nav sections */}
<nav <nav
style={{ flex: 1, display: "flex", flexDirection: "column", gap: 0 }} style={{ flex: 1, display: "flex", flexDirection: "column", gap: 0, width: "100%" }}
aria-label="Main navigation" aria-label="Main navigation"
> >
{navSections.map((section) => ( {navSections.map((section) => (
<div key={section.label} style={{ marginBottom: 8 }}> <div key={section.label} style={{ marginBottom: 8 }}>
<p style={{ {!compact && (
fontSize: 9, <p style={{
letterSpacing: "0.14em", fontSize: 9,
textTransform: "uppercase", letterSpacing: "0.14em",
color: "rgba(255,255,255,0.45)", textTransform: "uppercase",
padding: "8px 8px 4px", color: "rgba(255,255,255,0.45)",
fontWeight: 600, padding: "8px 8px 4px",
}}> fontWeight: 600,
{section.label} textAlign: "start",
</p> }}>
{section.label}
</p>
)}
{section.items.map((item) => { {section.items.map((item) => {
const allowed = item.permission === "read-dashboard" const allowed = item.permission === "read-dashboard"
@@ -86,11 +157,14 @@ export function Sidebar() {
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
title={compact ? item.label : undefined}
aria-label={item.label}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: compact ? "center" : "flex-start",
gap: 9, gap: 9,
padding: "7px 8px", padding: compact ? "10px" : "7px 8px",
borderRadius: 8, borderRadius: 8,
fontSize: 13, fontSize: 13,
fontWeight: active ? 500 : 400, fontWeight: active ? 500 : 400,
@@ -99,14 +173,15 @@ export function Sidebar() {
textDecoration: "none", textDecoration: "none",
transition: "var(--transition-base)", transition: "var(--transition-base)",
marginBottom: 2, marginBottom: 2,
textAlign: "start",
}} }}
> >
<i <i
className={`ti ${item.icon}`} className={`ti ${item.icon}`}
aria-hidden="true" aria-hidden="true"
style={{ fontSize: 16 }} style={{ fontSize: compact ? 18 : 16, flexShrink: 0 }}
/> />
{item.label} {!compact && item.label}
</Link> </Link>
); );
})} })}
@@ -114,20 +189,43 @@ export function Sidebar() {
))} ))}
</nav> </nav>
{/* User badge */} {/* User badge — collapses to an avatar dot in compact mode */}
<div style={{ {compact ? (
background: "rgba(255,255,255,0.12)", <div
borderRadius: 10, suppressHydrationWarning
padding: "10px 12px", title={user?.name ?? user?.userName ?? "—"}
marginTop: 8, style={{
}}> width: 36,
<p suppressHydrationWarning style={{ fontSize: 13, fontWeight: 500, color: "#fff", margin: 0 }}> height: 36,
{user?.name ?? user?.userName ?? ""} borderRadius: "var(--radius-full)",
</p> background: "rgba(255,255,255,0.18)",
<p suppressHydrationWarning style={{ fontSize: 11, color: "rgba(255,255,255,0.55)", marginTop: 2 }}> display: "flex",
{user?.role ?? "Signed in"} alignItems: "center",
</p> justifyContent: "center",
</div> fontSize: 13,
</aside> fontWeight: 600,
color: "#fff",
marginTop: 8,
}}
>
{(user?.name ?? user?.userName ?? "—").slice(0, 1)}
</div>
) : (
<div style={{
background: "rgba(255,255,255,0.12)",
borderRadius: 10,
padding: "10px 12px",
marginTop: 8,
width: "100%",
}}>
<p suppressHydrationWarning style={{ fontSize: 13, fontWeight: 500, color: "#fff", margin: 0, textAlign: "start" }}>
{user?.name ?? user?.userName ?? "—"}
</p>
<p suppressHydrationWarning style={{ fontSize: 11, color: "rgba(255,255,255,0.55)", marginTop: 2, textAlign: "start" }}>
{user?.role ?? "Signed in"}
</p>
</div>
)}
</>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clearAuth, getStoredUser } from "../../../lib/auth"; import { clearAuth, getStoredUser } from "@/src/lib/auth";
export function Topbar() { export function Topbar() {
const router = useRouter(); const router = useRouter();
@@ -24,14 +24,29 @@ export function Topbar() {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
/*
`justify-content: space-between` and `display: flex` are
direction-agnostic — they already flip with `dir` for free.
The title block (first child) lands at the inline-start edge
(right, in RTL) and the actions cluster (second child) lands
at inline-end (left). That matches the brief's "primary
actions easily accessible" requirement: in Arabic dashboards
the page title reads first at the right, and the role badge
+ logout sit together at the natural reading end — unchanged
from before, just now correctly positioned by `dir` instead
of by accident.
*/
}} }}
> >
<div> <div>
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}> <p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: 0, textAlign: "start" }}>
لوحة التحكم لوحة التحكم
</p> </p>
<p style={{ fontSize: 11, color: "var(--color-text-muted)", margin: 0 }}> <p style={{ fontSize: 11, color: "var(--color-text-muted)", margin: 0, textAlign: "start" }}>
Operations dashboard {/* English product label intentionally stays LTR-embedded so
"Operations dashboard" doesn't get its word order or
punctuation reversed by the surrounding RTL run. */}
<span className="ltr-embed">Operations dashboard</span>
</p> </p>
</div> </div>

View File

@@ -1,3 +1,222 @@
export default function AuditPage() { "use client";
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Audit log module is ready for compliance and activity review.</section>;
import { useCallback, useEffect, useReducer, useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI";
import { getStoredToken } from "@/src/lib/auth";
import { get } from "@/src/services/api";
// ── Types ──────────────────────────────────────────────────────────────────
interface AuditLog {
id: string;
action: string;
module?: string | null;
entityId?: string | null;
userId?: string | null;
userName?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
}
// ── State / Reducer ────────────────────────────────────────────────────────
interface State {
logs: AuditLog[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type Action =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; logs: AuditLog[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "CLEAR_ERR" };
function reducer(s: State, a: Action): State {
switch (a.type) {
case "LOAD_START": return { ...s, loading: true, error: null };
case "LOAD_OK": return { ...s, loading: false, logs: a.logs, total: a.total, pages: a.pages };
case "LOAD_ERR": return { ...s, loading: false, error: a.error };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
// ── Action badge ───────────────────────────────────────────────────────────
const ACTION_COLORS: Record<string, { bg: string; color: string; border: string }> = {
CREATE: { bg: "#DCFCE7", color: "#166534", border: "#BBF7D0" },
UPDATE: { bg: "#EFF6FF", color: "#1D4ED8", border: "#BFDBFE" },
DELETE: { bg: "#FEF2F2", color: "#DC2626", border: "#FECACA" },
LOGIN: { bg: "#F5F3FF", color: "#5B21B6", border: "#DDD6FE" },
LOGOUT: { bg: "#F1F5F9", color: "#475569", border: "#E2E8F0" },
};
function ActionBadge({ action }: { action: string }) {
const upper = action?.toUpperCase() ?? "";
const key = Object.keys(ACTION_COLORS).find(k => upper.includes(k)) ?? "CREATE";
const cfg = ACTION_COLORS[key];
return (
<span style={{
display: "inline-flex", alignItems: "center",
borderRadius: "var(--radius-full)", border: `1px solid ${cfg.border}`,
background: cfg.bg, padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: cfg.color, whiteSpace: "nowrap",
}}>
{action}
</span>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function AuditPage() {
const [state, dispatch] = useReducer(reducer, {
logs: [], loading: true, total: 0, pages: 1, error: null,
});
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [module, setModule] = useState("");
const loadLogs = useCallback(async (p: number, q: string, mod: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const params = new URLSearchParams({ page: String(p), limit: "15" });
if (q) params.set("search", q);
if (mod) params.set("module", mod);
const res = await get<{
data: { data: AuditLog[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } };
}>(`audit?${params}`, token);
const payload = (res as unknown as { data: { data: AuditLog[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }).data;
dispatch({
type: "LOAD_OK",
logs: payload.data ?? [],
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
});
} catch {
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل سجل التدقيق. يرجى المحاولة مجدداً." });
}
}, []);
useEffect(() => { loadLogs(page, search, module); }, [page, search, module, loadLogs]);
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
return (
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* 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)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
الامتثال والمراجعة
</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 }}>سجل التدقيق</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{state.total}</strong> سجل
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
<input type="text" placeholder="بحث بالإجراء أو المستخدم…" value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} dir="rtl"
style={{ width: 220, height: 40, padding: "0 0.75rem", borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)" }} />
<select value={module} onChange={e => { setModule(e.target.value); setPage(1); }} dir="rtl"
style={{ height: 40, padding: "0 0.75rem", borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, color: "var(--color-text-secondary)", outline: "none", fontFamily: "var(--font-sans)" }}>
<option value="">كل الوحدات</option>
{["User","Driver","Car","Trip","Order","Client","Role","Branch"].map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
</div>
</header>
{state.error && <Alert type="error" message={state.error} onClose={() => dispatch({ type: "CLEAR_ERR" })} />}
{/* Table */}
<div style={cardStyle}>
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr 1fr 1.5fr 1fr", ...thStyle }}>
<span>الإجراء</span>
<span>الوحدة</span>
<span>المستخدم</span>
<span>عنوان IP</span>
<span style={{ textAlign: "center" }}>الوقت</span>
</div>
{state.loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : state.logs.length === 0 ? (
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}>
لا توجد سجلات مطابقة
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{state.logs.map((log, i) => (
<li key={log.id} style={{
display: "grid", gridTemplateColumns: "1.5fr 1fr 1fr 1.5fr 1fr",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<ActionBadge action={log.action} />
{log.entityId && (
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>
{log.entityId.slice(0, 12)}
</p>
)}
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{log.module ?? "—"}</span>
<span style={{ color: "var(--color-text-secondary)" }}>{log.userName ?? log.userId ?? "—"}</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-muted)" }}>{log.ipAddress ?? "—"}</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(log.createdAt).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" })}
</span>
</li>
))}
</ul>
)}
{state.pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{state.pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي", action: () => setPage(p => Math.min(state.pages, p + 1)), disabled: page === state.pages },
].map(btn => (
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section>
);
} }

View File

@@ -1,3 +1,493 @@
export default function BranchesPage() { "use client";
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Branches module is ready for the location and hub management table.</section>;
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI";
import { getStoredToken } from "@/src/lib/auth";
import { get, post, put, del } from "@/src/services/api";
// ── Types ──────────────────────────────────────────────────────────────────
interface Branch {
id: string;
name: string;
address?: string | null;
phone?: string | null;
createdAt: string;
updatedAt: string;
}
interface BranchFormData {
name: string;
address: string;
phone: string;
}
// ── State / Reducer ────────────────────────────────────────────────────────
interface State {
branches: Branch[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type Action =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; branches: Branch[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; branch: Branch }
| { type: "UPDATE"; branch: Branch }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };
function reducer(s: State, a: Action): State {
switch (a.type) {
case "LOAD_START": return { ...s, loading: true, error: null };
case "LOAD_OK": return { ...s, loading: false, branches: a.branches, total: a.total, pages: a.pages };
case "LOAD_ERR": return { ...s, loading: false, error: a.error };
case "ADD": return { ...s, branches: [a.branch, ...s.branches], total: s.total + 1 };
case "UPDATE": return { ...s, branches: s.branches.map(b => b.id === a.branch.id ? a.branch : b) };
case "DELETE": return { ...s, branches: s.branches.filter(b => b.id !== a.id), total: Math.max(0, s.total - 1) };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
// ── Branch Form Modal ──────────────────────────────────────────────────────
function BranchFormModal({
editBranch,
onClose,
onSubmit,
}: {
editBranch: Branch | null;
onClose: () => void;
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
}) {
const isNew = editBranch === null;
const [form, setForm] = useState<BranchFormData>({
name: editBranch?.name ?? "",
address: editBranch?.address ?? "",
phone: editBranch?.phone ?? "",
});
const [errors, setErrors] = useState<Partial<BranchFormData>>({});
const [saving, setSaving] = useState(false);
const [apiErr, setApiErr] = useState("");
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { nameRef.current?.focus(); }, []);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
function validate(): boolean {
const e: Partial<BranchFormData> = {};
if (!form.name.trim()) e.name = "اسم الفرع مطلوب";
setErrors(e);
return Object.keys(e).length === 0;
}
async function handleSubmit(ev: React.FormEvent) {
ev.preventDefault();
if (!validate()) return;
setSaving(true);
setApiErr("");
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiErr("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}
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)",
};
return (
<div
role="dialog" aria-modal="true"
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",
}}
>
<div onClick={e => e.stopPropagation()} style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)", borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)", boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
}}>
<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 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "فرع جديد" : editBranch?.name}
</h2>
</div>
<button type="button" onClick={onClose} 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 onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{apiErr && <Alert type="error" message={apiErr} onClose={() => setApiErr("")} />}
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
اسم الفرع *
<input ref={nameRef} style={{ ...inputBase, ...(errors.name ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}) }}
value={form.name} onChange={e => { setForm(p => ({ ...p, name: e.target.value })); setErrors(p => ({ ...p, name: undefined })); }}
placeholder="فرع الرياض" dir="rtl" />
{errors.name && <span style={{ fontSize: 11, color: "var(--color-danger)" }}>{errors.name}</span>}
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
العنوان (اختياري)
<input style={inputBase} value={form.address}
onChange={e => setForm(p => ({ ...p, address: e.target.value }))}
placeholder="شارع الملك فهد، الرياض" dir="rtl" />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
رقم الهاتف (اختياري)
<input style={inputBase} value={form.phone}
onChange={e => setForm(p => ({ ...p, phone: e.target.value }))}
placeholder="+966 11 xxx xxxx" dir="ltr" />
</label>
<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>
);
}
// ── Delete Modal ───────────────────────────────────────────────────────────
function BranchDeleteModal({ branch, deleting, onCancel, onConfirm }: {
branch: Branch; deleting: boolean; onCancel: () => void; onConfirm: () => void;
}) {
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"
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",
}}>
<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 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>
);
}
// ── Toast ──────────────────────────────────────────────────────────────────
function Toast({ type, message }: { type: "success" | "error"; message: string }) {
const ok = type === "success";
return (
<div role="status" style={{
position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)", zIndex: 9999,
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>{message}</span>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function BranchesPage() {
const [state, dispatch] = useReducer(reducer, {
branches: [], loading: true, total: 0, pages: 1, error: null,
});
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [formTarget, setFormTarget] = useState<Branch | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
const [deleting, setDeleting] = useState(false);
const [notification, setNotification] = useState<{ type: "success" | "error"; message: string } | null>(null);
const notify = useCallback((n: { type: "success" | "error"; message: string }) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
}, []);
const loadBranches = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const qs = `?page=${p}&limit=12${q ? `&search=${encodeURIComponent(q)}` : ""}`;
const res = await get<{ data: { data: Branch[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }>(`branches${qs}`, token);
const payload = (res as unknown as { data: { data: Branch[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }).data;
dispatch({
type: "LOAD_OK",
branches: payload.data ?? [],
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
});
} catch {
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل الفروع. يرجى المحاولة مجدداً." });
}
}, []);
useEffect(() => { loadBranches(page, search); }, [page, search, loadBranches]);
const handleFormSubmit = useCallback(async (data: BranchFormData, isNew: boolean): Promise<boolean> => {
try {
const token = getStoredToken();
if (isNew) {
const res = await post<{ data: Branch }>("branches", data, token);
dispatch({ type: "ADD", branch: (res as unknown as { data: Branch }).data });
notify({ type: "success", message: "تم إضافة الفرع بنجاح." });
} else {
const res = await put<{ data: Branch }>(`branches/${(formTarget as Branch).id}`, data, token);
dispatch({ type: "UPDATE", branch: (res as unknown as { data: Branch }).data });
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
}
return true;
} catch (err) {
notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر حفظ الفرع." });
return false;
}
}, [formTarget, notify]);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const token = getStoredToken();
await del(`branches/${deleteTarget.id}`, token);
dispatch({ type: "DELETE", id: deleteTarget.id });
notify({ type: "success", message: "تم حذف الفرع بنجاح." });
setDeleteTarget(null);
} catch (err) {
notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر حذف الفرع." });
} finally {
setDeleting(false);
}
}, [deleteTarget, notify]);
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
return (
<>
{notification && <Toast type={notification.type} message={notification.message} />}
{formTarget !== false && (
<BranchFormModal
editBranch={formTarget}
onClose={() => setFormTarget(false)}
onSubmit={handleFormSubmit}
/>
)}
{deleteTarget && (
<BranchDeleteModal
branch={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* 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)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الفروع
</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 }}>الفروع</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{state.total}</strong> فرع
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ position: "relative", width: 256 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input type="text" placeholder="بحث بالاسم..." value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} dir="rtl"
style={{ width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)" }} />
</div>
<button type="button" onClick={() => setFormTarget(null)}
style={{ height: 40, padding: "0 1.125rem", borderRadius: "var(--radius-lg)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 7, fontFamily: "var(--font-sans)", boxShadow: "0 1px 4px rgba(37,99,235,.35)", whiteSpace: "nowrap" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة فرع
</button>
</div>
</div>
</header>
{state.error && <Alert type="error" message={state.error} onClose={() => dispatch({ type: "CLEAR_ERR" })} />}
{/* Table */}
<div style={cardStyle}>
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 2fr 1.5fr 1fr 100px", ...thStyle }}>
<span>الفرع</span>
<span>العنوان</span>
<span>الهاتف</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{state.loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : state.branches.length === 0 ? (
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
</p>
{!search && (
<button type="button" onClick={() => setFormTarget(null)}
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
أضف أول فرع
</button>
)}
</div>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{state.branches.map((b, i) => (
<li key={b.id} style={{
display: "grid", gridTemplateColumns: "2fr 2fr 1.5fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>{b.id.slice(0, 8)}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{b.address ?? "—"}</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>{b.phone ?? "—"}</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 6 }}>
<button type="button" title="تعديل" onClick={() => setFormTarget(b)}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #BFDBFE", background: "#EFF6FF", color: "#1D4ED8", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" 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" title="حذف" onClick={() => setDeleteTarget(b)}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #FECACA", background: "#FEF2F2", color: "#DC2626", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</button>
</div>
</li>
))}
</ul>
)}
{state.pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{state.pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي", action: () => setPage(p => Math.min(state.pages, p + 1)), disabled: page === state.pages },
].map(btn => (
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section>
</>
);
} }

View File

@@ -2,13 +2,13 @@
// app/(dashboard)/cars/page.tsx // app/(dashboard)/cars/page.tsx
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Spinner, Alert } from "@/Components/UI"; import { Spinner, Alert } from "@/src/Components/UI";
import { CarFormModal } from "@/Components/car/CarFormModal"; import { CarFormModal } from "@/src/Components/car/CarFormModal";
import { CarDetailPanel } from "@/Components/car/CarDetailPanel"; import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
import { CarDeleteModal } from "@/Components/car/CarDeleteModal"; import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
import { useCars, useCarMutations, useToast } from "@/hooks/useCars"; import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/types/car"; import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/types/car"; import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
// ── Toast ───────────────────────────────────────────────────────────────────── // ── Toast ─────────────────────────────────────────────────────────────────────

View File

@@ -2,18 +2,18 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Alert } from "@/Components/UI"; import { Alert } from "@/src/Components/UI";
import { Toast } from "@/Components/Client/Toast"; import { Toast } from "@/src/Components/Client/Toast";
import { useClientAddresses } from "@/hooks/useClientAddresses"; import { useClientAddresses } from "@/src/hooks/useClientAddresses";
import { clientService } from "@/services/client.service"; import { clientService } from "@/src/services/client.service";
import { getStoredToken } from "@/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import type { import type {
Client, Client,
ClientAddress, ClientAddress,
ClientAddressFormData, ClientAddressFormData,
} from "@/types/client"; } from "@/src/types/client";
import { AddressFormModal } from "@/Components/Client/Addressformmodal"; import { AddressFormModal } from "@/src/Components/Client/Addressformmodal";
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal"; import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────

View File

@@ -2,13 +2,13 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert } from "@/Components/UI"; import { Alert } from "@/src/Components/UI";
import { Toast } from "@/Components/Client/Toast"; import { Toast } from "@/src/Components/Client/Toast";
import { useClients } from "@/hooks/useClients"; import { useClients } from "@/src/hooks/useClients";
import type { Client, ClientFormData } from "@/types/client"; import type { Client, ClientFormData } from "@/src/types/client";
import { ClientFormModal } from "@/Components/Client/Clientformmodal"; import { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
import { ClientTable } from "@/Components/Client/Clienttable"; import { ClientTable } from "@/src/Components/Client/Clienttable";
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal"; import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
export default function ClientsPage() { export default function ClientsPage() {
const router = useRouter(); const router = useRouter();

View File

@@ -2,31 +2,22 @@
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 "@/Components/UI"; import { Spinner } from "@/src/Components/UI";
import { DriverFormModal } from "@/Components/Driver/DriverFormModal"; import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal"; import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
import { DriverReportPanel } from "@/Components/Driver_Report/driverReport"; import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
import { driverService } from "@/services"; import { driverService } from "@/services";
import { getStoredToken } from "@/lib/auth"; import { getStoredToken } from "@/lib/auth";
import type { import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
Driver, import { DRIVER_STATUS_MAP, DRIVER_CARD_TYPE_MAP, NATIONAL_ID_TYPE_MAP } from "@/src/types/driver";
CreateDriverPayload, import { PhotoCard } from "@/src/Components/Driver/DriverPhotos";
UpdateDriverPayload,
} from "@/types/driver";
import {
DRIVER_STATUS_MAP,
DRIVER_CARD_TYPE_MAP,
NATIONAL_ID_TYPE_MAP,
} from "@/types/driver";
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── 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", {
year: "numeric", year: "numeric", month: "long", day: "numeric",
month: "long",
day: "numeric",
}); });
} }
@@ -37,40 +28,21 @@ function isExpiringSoon(iso?: string | null): boolean {
// ── Sub-components ──────────────────────────────────────────────────────────── // ── Sub-components ────────────────────────────────────────────────────────────
function SectionCard({ function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return ( return (
<div <div style={{
style={{ borderRadius: "var(--radius-xl)",
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
border: "1px solid var(--color-border)", background: "var(--color-surface)",
background: "var(--color-surface)", overflow: "hidden",
overflow: "hidden", boxShadow: "var(--shadow-card)",
boxShadow: "var(--shadow-card)", }}>
}} <div style={{
> padding: "0.875rem 1.5rem",
<div borderBottom: "1px solid var(--color-border)",
style={{ background: "var(--color-surface-muted)",
padding: "0.875rem 1.5rem", }}>
borderBottom: "1px solid var(--color-border)", <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: 0 }}>
background: "var(--color-surface-muted)",
}}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: 0,
}}
>
{title} {title}
</p> </p>
</div> </div>
@@ -79,123 +51,59 @@ function SectionCard({
); );
} }
function DetailRow({ function DetailRow({ label, value, mono = false, warn = false }: {
label, label: string; value: string; mono?: boolean; warn?: boolean;
value,
mono = false,
warn = false,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
}) { }) {
return ( return (
<div <div style={{
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline",
display: "flex", padding: "0.55rem 0", borderBottom: "1px solid var(--color-border)",
justifyContent: "space-between", }}>
alignItems: "baseline", <span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
padding: "0.55rem 0", <span style={{
borderBottom: "1px solid var(--color-border)", fontSize: 13,
}} fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
> color: warn ? "#D97706" : "var(--color-text-primary)",
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}> fontWeight: warn ? 600 : 400,
{label} maxWidth: "60%", textAlign: "left", wordBreak: "break-word",
</span> }}>
<span {warn && value !== "—" ? "⚠ " : ""}{value}
style={{
fontSize: 13,
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
color: warn ? "#D97706" : "var(--color-text-primary)",
fontWeight: warn ? 600 : 400,
maxWidth: "60%",
textAlign: "left",
wordBreak: "break-word",
}}
>
{warn && value !== "—" ? "⚠ " : ""}
{value}
</span> </span>
</div> </div>
); );
} }
function PhotoCard({ url, label }: { url?: string | null; label: string }) {
const [imgError, setImgError] = useState(false);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-muted)" }}>
{label}
</span>
<div
style={{
width: "100%",
aspectRatio: "4/3",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{url && !imgError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={label}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setImgError(true)}
/>
) : (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)}
</div>
{url && !imgError && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 11, color: "#2563EB", textDecoration: "underline", textAlign: "center" }}
>
عرض الصورة
</a>
)}
</div>
);
}
// ── Page ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
export default function DriverDetailPage() { export default function DriverDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const driverId = params?.driverId as string; const driverId = params?.driverId as string;
const [driver, setDriver] = useState<Driver | null>(null); const [driver, setDriver] = useState<Driver | 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 [avatarError, setAvatarError] = useState(false); const [avatarError, setAvatarError] = useState(false);
// Reset avatar error when photo changes after an update
useEffect(() => {
console.log(driver?.photoUrl);
setAvatarError(false);
}, [driver?.photoUrl]);
// Toast shown after edit/delete actions on this page
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
// ── Modal state ───────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const showToast = useCallback((type: "success" | "error", message: string) => {
setToast({ type, message });
setTimeout(() => setToast(null), 4000);
}, []);
// ── Load driver ─────────────────────────────────────────────────────────── // ── Load driver ───────────────────────────────────────────────────────────
const loadDriver = useCallback(async () => { const loadDriver = useCallback(async () => {
if (!driverId) return; if (!driverId) return;
@@ -212,27 +120,37 @@ export default function DriverDetailPage() {
} }
}, [driverId]); }, [driverId]);
useEffect(() => { useEffect(() => { loadDriver(); }, [loadDriver]);
loadDriver();
}, [loadDriver]);
// ── Edit submit ─────────────────────────────────────────────────────────── // ── Edit submit ───────────────────────────────────────────────────────────
const handleEditSubmit = useCallback( const handleEditSubmit = useCallback(
async ( async (payload: CreateDriverPayload | UpdateDriverPayload, _isNew: boolean): Promise<boolean> => {
payload: CreateDriverPayload | UpdateDriverPayload,
_isNew: boolean,
): Promise<boolean> => {
if (!driver) return false; if (!driver) return false;
try { try {
const token = getStoredToken(); const token = getStoredToken();
await driverService.update(driver.id, payload as UpdateDriverPayload, token); const typedPayload = payload as UpdateDriverPayload & {
photo?: File; nationalPhoto?: File; driverCardPhoto?: File;
};
const hasFiles = typedPayload.photo || typedPayload.nationalPhoto || typedPayload.driverCardPhoto;
if (hasFiles) {
// Use multipart — JSON.stringify converts File to {} which fails validation
await driverService.updateWithImages(driver.id, typedPayload, token);
} else {
await driverService.update(driver.id, typedPayload, token);
}
// Re-fetch to reflect updated status, name, photos, etc.
await loadDriver(); await loadDriver();
showToast("success", "تم تحديث بيانات السائق بنجاح.");
return true; return true;
} catch { } catch (err) {
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق.";
showToast("error", message);
return false; return false;
} }
}, },
[driver, loadDriver], [driver, loadDriver, showToast],
); );
// ── Delete confirm ──────────────────────────────────────────────────────── // ── Delete confirm ────────────────────────────────────────────────────────
@@ -243,27 +161,19 @@ export default function DriverDetailPage() {
const token = getStoredToken(); const token = getStoredToken();
await driverService.delete(driver.id, token); await driverService.delete(driver.id, token);
router.push("/dashboard/drivers"); router.push("/dashboard/drivers");
} catch { } catch (err) {
const message = err instanceof Error ? err.message : "تعذّر حذف السائق.";
showToast("error", message);
setDeleting(false); setDeleting(false);
} }
}, [driver, router]); }, [driver, router, showToast]);
// ── Status config ─────────────────────────────────────────────────────────
const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null; const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null;
// ── Render: Loading ─────────────────────────────────────────────────────── // ── Render: Loading ───────────────────────────────────────────────────────
if (loading) { if (loading) {
return ( return (
<div <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "6rem 0", color: "var(--color-text-muted)" }}>
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "6rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" /> <Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ التحميل</span> <span style={{ fontSize: 14 }}>جارٍ التحميل</span>
</div> </div>
@@ -273,37 +183,14 @@ export default function DriverDetailPage() {
// ── Render: Error ───────────────────────────────────────────────────────── // ── Render: Error ─────────────────────────────────────────────────────────
if (error || !driver) { if (error || !driver) {
return ( return (
<div <div style={{ maxWidth: 480, margin: "4rem auto", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "1.5rem", textAlign: "center" }}>
style={{ <p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}> {error ?? "السائق غير موجود"}</p>
maxWidth: 480, <button type="button" onClick={() => router.back()} style={{
margin: "4rem auto", marginTop: "1rem", height: 38, padding: "0 1.25rem",
borderRadius: "var(--radius-xl)", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
border: "1px solid #FECACA", background: "var(--color-surface)", fontSize: 13, fontWeight: 600,
background: "#FEF2F2", color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)",
padding: "1.5rem", }}>
textAlign: "center",
}}
>
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
{error ?? "السائق غير موجود"}
</p>
<button
type="button"
onClick={() => router.back()}
style={{
marginTop: "1rem",
height: 38,
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: "pointer",
fontFamily: "var(--font-sans)",
}}
>
رجوع رجوع
</button> </button>
</div> </div>
@@ -315,35 +202,32 @@ export default function DriverDetailPage() {
<> <>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Toast notification ── */}
{toast && (
<div style={{
borderRadius: "var(--radius-lg)",
border: `1px solid ${toast.type === "success" ? "#BBF7D0" : "#FECACA"}`,
background: toast.type === "success" ? "#DCFCE7" : "#FEF2F2",
padding: "0.75rem 1.25rem",
fontSize: 13,
fontWeight: 600,
color: toast.type === "success" ? "#166534" : "#DC2626",
}}>
{toast.type === "success" ? "✓ " : "⚠ "}{toast.message}
</div>
)}
{/* ── Page header ── */} {/* ── Page header ── */}
<header <header style={{
style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)", background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
border: "1px solid var(--color-border)", }}>
background: "var(--color-surface)", <button type="button" onClick={() => router.back()} style={{
padding: "1.5rem 2rem", display: "inline-flex", alignItems: "center", gap: 6,
boxShadow: "var(--shadow-card)", fontSize: 12, fontWeight: 600, color: "var(--color-text-muted)",
}} background: "none", border: "none", cursor: "pointer", padding: 0,
> marginBottom: "1rem", fontFamily: "var(--font-sans)",
{/* Back link */} }}>
<button
type="button"
onClick={() => router.back()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
marginBottom: "1rem",
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M19 12H5M12 5l-7 7 7 7" /> <path d="M19 12H5M12 5l-7 7 7 7" />
</svg> </svg>
@@ -351,68 +235,35 @@ export default function DriverDetailPage() {
</button> </button>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
{/* Avatar + name */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}> <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div <div style={{
style={{ width: 72, height: 72, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
width: 72, border: "2px solid var(--color-brand-200)", background: "var(--color-surface-muted)",
height: 72, display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: "50%", }}>
overflow: "hidden",
flexShrink: 0,
border: "2px solid var(--color-brand-200)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{driver.photoUrl && !avatarError ? ( {driver.photoUrl && !avatarError ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img src={driver.photoUrl} alt={driver.name} style={{ width: "100%", height: "100%", objectFit: "cover" }} onError={() => setAvatarError(true)} />
src={driver.photoUrl}
alt={driver.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setAvatarError(true)}
/>
) : ( ) : (
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}> <span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>{driver.name.charAt(0)}</span>
{driver.name.charAt(0)}
</span>
)} )}
</div> </div>
<div> <div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>ملف السائق</p>
ملف السائق <h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>{driver.name}</h1>
</p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{driver.name}
</h1>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
{driver.userName && ( {driver.userName && (
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}> <span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>@{driver.userName}</span>
@{driver.userName}
</span>
)} )}
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}> <span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>{driver.phone}</span>
{driver.phone}
</span>
{statusConfig && ( {statusConfig && (
<span <span style={{
style={{ borderRadius: "var(--radius-full)", border: `1px solid ${statusConfig.border}`,
borderRadius: "var(--radius-full)", background: statusConfig.bg, padding: "0.2rem 0.625rem",
border: `1px solid ${statusConfig.border}`, fontSize: 11, fontWeight: 700, color: statusConfig.color,
background: statusConfig.bg, display: "inline-flex", alignItems: "center", gap: 5,
padding: "0.2rem 0.625rem", }}>
fontSize: 11,
fontWeight: 700,
color: statusConfig.color,
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
{statusConfig.label} {statusConfig.label}
</span> </span>
@@ -421,45 +272,20 @@ export default function DriverDetailPage() {
</div> </div>
</div> </div>
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem" }}> <div style={{ display: "flex", gap: "0.75rem" }}>
<button <button type="button" onClick={() => setDeleteOpen(true)} style={{
type="button" height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-lg)",
onClick={() => setDeleteOpen(true)} border: "1px solid #FECACA", background: "#FEF2F2",
style={{ fontSize: 13, fontWeight: 700, color: "#DC2626", cursor: "pointer", fontFamily: "var(--font-sans)",
height: 40, }}>
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "1px solid #FECACA",
background: "#FEF2F2",
fontSize: 13,
fontWeight: 700,
color: "#DC2626",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
حذف السائق حذف السائق
</button> </button>
<button <button type="button" onClick={() => setEditOpen(true)} style={{
type="button" height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-lg)",
onClick={() => setEditOpen(true)} border: "none", background: "var(--color-brand-600)",
style={{ fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer",
height: 40, display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)",
padding: "0 1.25rem", }}>
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<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="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" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
@@ -471,103 +297,58 @@ export default function DriverDetailPage() {
</header> </header>
{/* ── Content grid ── */} {/* ── Content grid ── */}
<div <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.5rem",
}}
>
{/* Personal Info */}
<SectionCard title="البيانات الشخصية"> <SectionCard title="البيانات الشخصية">
<DetailRow label="الاسم الكامل" value={driver.name} /> <DetailRow label="الاسم الكامل" value={driver.name} />
<DetailRow label="رقم الجوال" value={driver.phone} mono /> <DetailRow label="رقم الجوال" value={driver.phone} mono />
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} /> <DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
<DetailRow label="العنوان" value={driver.address ?? "—"} /> <DetailRow label="العنوان" value={driver.address ?? "—"} />
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} /> <DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} /> <DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} /> <DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
</SectionCard> </SectionCard>
{/* ID & GOSI */}
<SectionCard title="الهوية والتأمينات"> <SectionCard title="الهوية والتأمينات">
<DetailRow <DetailRow label="نوع الهوية" value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"} />
label="نوع الهوية"
value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"}
/>
<DetailRow label="رقم الهوية" value={driver.nationalId ?? "—"} mono /> <DetailRow label="رقم الهوية" value={driver.nationalId ?? "—"} mono />
<DetailRow <DetailRow label="انتهاء الهوية" value={fmtDate(driver.nationalIdExpiry)} warn={isExpiringSoon(driver.nationalIdExpiry)} />
label="انتهاء الهوية"
value={fmtDate(driver.nationalIdExpiry)}
warn={isExpiringSoon(driver.nationalIdExpiry)}
/>
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono /> <DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
</SectionCard> </SectionCard>
{/* License */}
<SectionCard title="بيانات رخصة القيادة"> <SectionCard title="بيانات رخصة القيادة">
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono /> <DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono />
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} /> <DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
<DetailRow <DetailRow label="انتهاء الرخصة" value={fmtDate(driver.licenseExpiry)} warn={isExpiringSoon(driver.licenseExpiry)} />
label="انتهاء الرخصة"
value={fmtDate(driver.licenseExpiry)}
warn={isExpiringSoon(driver.licenseExpiry)}
/>
</SectionCard> </SectionCard>
{/* Driver Card */}
<SectionCard title="بطاقة السائق"> <SectionCard title="بطاقة السائق">
<DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono /> <DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono />
<DetailRow <DetailRow label="نوع البطاقة" value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"} />
label="نوع البطاقة" <DetailRow label="انتهاء البطاقة" value={fmtDate(driver.driverCardExpiry)} warn={isExpiringSoon(driver.driverCardExpiry)} />
value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"}
/>
<DetailRow
label="انتهاء البطاقة"
value={fmtDate(driver.driverCardExpiry)}
warn={isExpiringSoon(driver.driverCardExpiry)}
/>
</SectionCard> </SectionCard>
{/* System Info */}
<SectionCard title="معلومات النظام"> <SectionCard title="معلومات النظام">
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono /> <DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono />
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} /> <DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} /> <DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
</SectionCard> </SectionCard>
{/* Status History */}
{driver.statusHistory && driver.statusHistory.length > 0 && ( {driver.statusHistory && driver.statusHistory.length > 0 && (
<SectionCard title="سجل الحالات"> <SectionCard title="سجل الحالات">
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}> <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{driver.statusHistory.map((h) => { {driver.statusHistory.map((h) => {
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active; const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
return ( return (
<div <div key={h.id} style={{
key={h.id} display: "flex", justifyContent: "space-between", alignItems: "center",
style={{ borderRadius: "var(--radius-md)", border: `1px solid ${s.border}`,
display: "flex", background: s.bg, padding: "0.5rem 0.875rem",
justifyContent: "space-between", }}>
alignItems: "center",
borderRadius: "var(--radius-md)",
border: `1px solid ${s.border}`,
background: s.bg,
padding: "0.5rem 0.875rem",
}}
>
<div> <div>
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}> <span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
{s.label} {h.reason && <span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}> {h.reason}</span>}
</span>
{h.reason && (
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
{h.reason}
</span>
)}
</div> </div>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}> <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(h.createdAt)}</span>
{fmtDate(h.createdAt)}
</span>
</div> </div>
); );
})} })}
@@ -576,31 +357,22 @@ export default function DriverDetailPage() {
)} )}
</div> </div>
{/* ── Photos section (full width) ── */}
<SectionCard title="الصور والمستندات"> <SectionCard title="الصور والمستندات">
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1.25rem" }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1.25rem" }}>
<PhotoCard url={driver.photoUrl} label="صورة السائق" /> <PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" /> <PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة بطاقة السائق" /> <PhotoCard url={driver.driverCardPhotoUrl} label="صورة بطاقة السائق" />
</div> </div>
</SectionCard> </SectionCard>
{/* ── Report section (full width) ── */} <div style={{
<div borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
style={{ background: "var(--color-surface)", boxShadow: "var(--shadow-card)", padding: "1.5rem",
borderRadius: "var(--radius-xl)", }}>
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
}}
>
<DriverReportPanel driverId={driver.id} /> <DriverReportPanel driverId={driver.id} />
</div> </div>
</section> </section>
{/* ── Edit modal ── */}
{editOpen && ( {editOpen && (
<DriverFormModal <DriverFormModal
editDriver={driver} editDriver={driver}
@@ -610,7 +382,6 @@ export default function DriverDetailPage() {
/> />
)} )}
{/* ── Delete modal ── */}
{deleteOpen && ( {deleteOpen && (
<DriverDeleteModal <DriverDeleteModal
driver={driver} driver={driver}

View File

@@ -1,15 +1,12 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Alert, Spinner } from "@/Components/UI"; import { Alert, Spinner } from "@/src/Components/UI";
import { DriverFormModal } from "@/Components/Driver/DriverFormModal"; import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal"; import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
import { driverService } from "@/services"; import { useDrivers } from "@/src/hooks/useDriver";
import { getStoredToken } from "@/lib/auth"; import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver";
import { useDrivers } from "@/hooks/useDriver"; import { DriverDetailPanel } from "@/src/Components/Driver/DriverDetailPanel";
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/types/driver";
import { DriverDetailPanel } from "@/Components/Driver/DriverDetailPanel";
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -46,13 +43,28 @@ const thStyle: React.CSSProperties = {
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
}; };
// Shared across header + rows — keep these in sync or columns will misalign.
const ROW_GRID_COLUMNS = "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr 0.8fr";
const iconBtnBase: React.CSSProperties = {
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-md)",
cursor: "pointer",
flexShrink: 0,
};
// ── Page Component ──────────────────────────────────────────────────────────── // ── Page Component ────────────────────────────────────────────────────────────
export default function DriversPage() { export default function DriversPage() {
const { const {
drivers, loading, error, total, pages, page, drivers, loading, error, total, pages, page,
search, setPage, handleSearch, clearError, search, setPage, handleSearch, clearError,
deleteDriver, notification, reload, createDriver, updateDriver, deleteDriver,
notification, reload,
} = useDrivers(); } = useDrivers();
// ── Panel / modal state ─────────────────────────────────────────────────── // ── Panel / modal state ───────────────────────────────────────────────────
@@ -60,6 +72,8 @@ export default function DriversPage() {
const [formDriver, setFormDriver] = useState<Driver | null | "new">(null); const [formDriver, setFormDriver] = useState<Driver | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null); const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// Bumped after a successful edit to force the detail panel to re-fetch
const [panelRefreshKey, setPanelRefreshKey] = useState(0);
// ── Handlers ────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────
const handleEdit = useCallback((driver: Driver) => { const handleEdit = useCallback((driver: Driver) => {
@@ -77,34 +91,33 @@ export default function DriversPage() {
payload: CreateDriverPayload | UpdateDriverPayload, payload: CreateDriverPayload | UpdateDriverPayload,
isNew: boolean, isNew: boolean,
): Promise<boolean> => { ): Promise<boolean> => {
try { let ok: boolean;
const token = getStoredToken();
if (isNew) {
// Use multipart if there are file fields
const hasFiles =
(payload as CreateDriverPayload & { photo?: File }).photo ||
(payload as CreateDriverPayload & { nationalPhoto?: File }).nationalPhoto ||
(payload as CreateDriverPayload & { driverCardPhoto?: File }).driverCardPhoto;
if (hasFiles) { if (isNew) {
await driverService.createWithImages( ok = await createDriver(
payload as Parameters<typeof driverService.createWithImages>[0], payload as CreateDriverPayload & {
token, photo?: File;
); nationalPhoto?: File;
} else { driverCardPhoto?: File;
await driverService.create(payload as CreateDriverPayload, token); },
} );
} else { } else {
const id = (formDriver as Driver).id; const id = (formDriver as Driver).id;
await driverService.update(id, payload as UpdateDriverPayload, token); ok = await updateDriver(
} id,
reload(); payload as UpdateDriverPayload & {
return true; photo?: File;
} catch { nationalPhoto?: File;
return false; driverCardPhoto?: File;
},
);
// Re-fetch detail panel so updated status is visible immediately
if (ok && selectedDriverId) setPanelRefreshKey((k) => k + 1);
} }
return ok;
}, },
[formDriver, reload], [formDriver, createDriver, updateDriver, selectedDriverId],
); );
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {
@@ -198,10 +211,7 @@ export default function DriversPage() {
{/* ── Notifications ── */} {/* ── Notifications ── */}
{notification && ( {notification && (
<Alert <Alert type={notification.type} message={notification.message} />
type={notification.type}
message={notification.message}
/>
)} )}
{error && ( {error && (
<Alert type="error" message={error} onClose={clearError} /> <Alert type="error" message={error} onClose={clearError} />
@@ -213,7 +223,7 @@ export default function DriversPage() {
dir="rtl" dir="rtl"
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr", gridTemplateColumns: ROW_GRID_COLUMNS,
...thStyle, ...thStyle,
}} }}
> >
@@ -223,6 +233,7 @@ export default function DriversPage() {
<span>انتهاء الرخصة</span> <span>انتهاء الرخصة</span>
<span>انتهاء الهوية</span> <span>انتهاء الهوية</span>
<span>الحالة</span> <span>الحالة</span>
<span>إجراءات</span>
</div> </div>
{loading ? ( {loading ? (
@@ -247,7 +258,7 @@ export default function DriversPage() {
onClick={() => setSelectedDriverId(d.id)} onClick={() => setSelectedDriverId(d.id)}
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr", gridTemplateColumns: ROW_GRID_COLUMNS,
alignItems: "center", gap: "0.5rem", alignItems: "center", gap: "0.5rem",
padding: "0.875rem 1.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
@@ -259,29 +270,18 @@ export default function DriversPage() {
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")} onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")}
onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")} onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")}
> >
{/* Name + phone */}
<div> <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
</div> </div>
{/* Branch */}
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span> <span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
{/* Nationality */}
<span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span> <span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span>
{/* License expiry */}
<span style={{ fontSize: 12, fontWeight: licWarn ? 600 : 400, color: licWarn ? "#D97706" : "var(--color-text-secondary)" }}> <span style={{ fontSize: 12, fontWeight: licWarn ? 600 : 400, color: licWarn ? "#D97706" : "var(--color-text-secondary)" }}>
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)} {licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
</span> </span>
{/* National ID expiry */}
<span style={{ fontSize: 12, fontWeight: idWarn ? 600 : 400, color: idWarn ? "#D97706" : "var(--color-text-secondary)" }}> <span style={{ fontSize: 12, fontWeight: idWarn ? 600 : 400, color: idWarn ? "#D97706" : "var(--color-text-secondary)" }}>
{idWarn && "⚠ "}{fmtDate((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry)} {idWarn && "⚠ "}{fmtDate((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry)}
</span> </span>
{/* Status badge */}
<span style={{ <span style={{
display: "inline-flex", alignItems: "center", gap: 5, display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)", borderRadius: "var(--radius-full)",
@@ -294,6 +294,50 @@ export default function DriversPage() {
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} />
{statusCfg.label} {statusCfg.label}
</span> </span>
{/* ── Inline row actions: edit / delete ──
stopPropagation is required on both buttons so the
click doesn't bubble up to the <li> onClick and
open the detail panel as well. */}
<div style={{ display: "flex", gap: "0.4rem" }}>
<button
type="button"
aria-label="تعديل السائق"
title="تعديل"
onClick={(e) => { e.stopPropagation(); handleEdit(d); }}
style={{
...iconBtnBase,
border: "1px solid var(--color-brand-200)",
background: "var(--color-brand-50, #EFF6FF)",
color: "var(--color-brand-600)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<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"
aria-label="حذف السائق"
title="حذف"
onClick={(e) => { e.stopPropagation(); handleDelete(d); }}
style={{
...iconBtnBase,
border: "1px solid #FECACA",
background: "#FEF2F2",
color: "#DC2626",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</button>
</div>
</li> </li>
); );
})} })}
@@ -344,9 +388,10 @@ export default function DriversPage() {
</div> </div>
</section> </section>
{/* ── Detail panel ── */} {/* ── Detail panel — key forces re-fetch after successful edit ── */}
{selectedDriverId && ( {selectedDriverId && (
<DriverDetailPanel <DriverDetailPanel
key={`${selectedDriverId}-${panelRefreshKey}`}
driverId={selectedDriverId} driverId={selectedDriverId}
onClose={() => setSelectedDriverId(null)} onClose={() => setSelectedDriverId(null)}
onEdit={handleEdit} onEdit={handleEdit}

View File

@@ -11,8 +11,23 @@ export default function DashboardLayout({
className="flex min-h-screen" className="flex min-h-screen"
style={{ background: "var(--color-surface-muted)" }} style={{ background: "var(--color-surface-muted)" }}
> >
{/*
Sidebar is still the first flex child. With dir="rtl" on <html>
(set in app/layout.tsx) this is enough to place the full
sidebar on the right with no positional CSS — see Sidebar.tsx.
*/}
<Sidebar /> <Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
{/*
The compact icon rail (md and below) is `position: fixed`, so it
doesn't take up flex space. This wrapper reserves room for it
with `paddingInlineEnd`, which means "right padding in RTL,
left padding in LTR" — it never needs to be touched again if
direction changes.
*/}
<div
className="flex min-w-0 flex-1 flex-col md:[padding-inline-end:var(--sidebar-width-compact)] lg:[padding-inline-end:0]"
>
<Topbar /> <Topbar />
<main className="flex-1 p-6">{children}</main> <main className="flex-1 p-6">{children}</main>
</div> </div>

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getStoredToken } from "@/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { get } from "@/services/api"; import { get } from "@/src/services/api";
import { Spinner, Alert } from "@/Components/UI"; import { Spinner, Alert } from "@/src/Components/UI";
interface Order { interface Order {
id: string; id: string;

View File

@@ -3,9 +3,9 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useDashboardSummary } from "../../hooks/useDashboardSummary"; import { useDashboardSummary } from "@/src/hooks/useDashboardSummary";
import { clearAuth, getStoredUser } from "../../lib/auth"; import { clearAuth, getStoredUser } from "@/src/lib/auth";
import { Spinner, Alert } from "../../Components/UI"; import { Spinner, Alert } from "@/src/Components/UI";
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
@@ -48,15 +48,15 @@ export default function DashboardPage() {
backdropFilter: "blur(16px)", backdropFilter: "blur(16px)",
}} }}
> >
<p style={{ fontSize: 11, letterSpacing: "0.35em", textTransform: "uppercase", color: "#67E8F9" }}> <p style={{ fontSize: 11, letterSpacing: "0.35em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>
لوحة العمليات لوحة العمليات
</p> </p>
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 600, color: "var(--color-text-dark-primary)", margin: 0 }}> <h1 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 600, color: "var(--color-text-dark-primary)", margin: 0, textAlign: "start" }}>
ذكاء الأسطول في لمحة سريعة ذكاء الأسطول في لمحة سريعة
</h1> </h1>
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6 }}> <p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6, textAlign: "start" }}>
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات. عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
</p> </p>
</div> </div>
@@ -111,23 +111,28 @@ export default function DashboardPage() {
}} }}
> >
<div style={{ height: 3, borderRadius: 999, background: item.accent, marginBottom: "1rem" }} /> <div style={{ height: 3, borderRadius: 999, background: item.accent, marginBottom: "1rem" }} />
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-dark-muted)" }}> <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
{item.label} {item.label}
</p> </p>
<p style={{ fontSize: "2.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}> <p style={{ fontSize: "2.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
{item.value} {item.value}
</p> </p>
</article> </article>
))} ))}
</div> </div>
{/* ── Active trips + alerts ── */} {/* ── Active trips + alerts ──
Column order in the template below stays [trips, alerts] —
same reading order as before. In RTL this now renders
trips on the right (read first) and alerts on the left,
which is the correct mirror; no class change needed (see
grid + RTL note in the project report). */}
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}> <article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9" }}>الرحلات النشطة</p> <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>الرحلات النشطة</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>تقدم الرحلات</h2> <h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>تقدم الرحلات</h2>
</div> </div>
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#A7F3D0" }}> <span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#A7F3D0" }}>
مباشر مباشر
@@ -138,44 +143,54 @@ export default function DashboardPage() {
<div key={trip.id} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}> <div key={trip.id} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)" }}>{trip.tripNumber}</p> <p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>{trip.title}</p> <span className="ltr-embed">{trip.tripNumber}</span>
</p>
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>{trip.title}</p>
</div> </div>
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}> <span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
{trip.progress}% {trip.progress}%
</span> </span>
</div> </div>
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}> <div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
{/*
Progress fill: width % growing from the bar's
start edge is correct in both directions since
`width` itself is non-directional — the bar's
own inline-start (right, in RTL) is where the
fill begins, matching how the rest of the RTL
layout fills from the right. No change needed.
*/}
<div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} /> <div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
</div> </div>
</div> </div>
)) : ( )) : (
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>لا توجد رحلات نشطة حالياً.</p> <p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد رحلات نشطة حالياً.</p>
)} )}
</div> </div>
</article> </article>
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}> <article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCD34D" }}>تنبيهات الالتزام</p> <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCD34D", textAlign: "start" }}>تنبيهات الالتزام</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>التجديدات القادمة</h2> <h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>التجديدات القادمة</h2>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}> <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{(alerts?.expiringCars || []).slice(0, 3).map((item, i) => ( {(alerts?.expiringCars || []).slice(0, 3).map((item, i) => (
<div key={`car-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,191,36,0.20)", background: "rgba(251,191,36,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}> <div key={`car-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,191,36,0.20)", background: "rgba(251,191,36,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
{String((item as Record<string, unknown>).message || "Vehicle expiry alert")} {String((item as Record<string, unknown>).message || "Vehicle expiry alert")}
</div> </div>
))} ))}
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => ( {(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => (
<div key={`driver-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,113,133,0.20)", background: "rgba(244,63,94,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}> <div key={`driver-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,113,133,0.20)", background: "rgba(244,63,94,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
{String((item as Record<string, unknown>).message || "Driver expiry alert")} {String((item as Record<string, unknown>).message || "Driver expiry alert")}
</div> </div>
))} ))}
{(alerts?.upcomingMaint || []).slice(0, 3).map((item, i) => ( {(alerts?.upcomingMaint || []).slice(0, 3).map((item, i) => (
<div key={`maint-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(103,232,249,0.20)", background: "rgba(103,232,249,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}> <div key={`maint-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(103,232,249,0.20)", background: "rgba(103,232,249,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
{String((item as Record<string, unknown>).message || "Maintenance alert")} {String((item as Record<string, unknown>).message || "Maintenance alert")}
</div> </div>
))} ))}
{!(alerts?.expiringCars?.length || alerts?.expiringDrivers?.length || alerts?.upcomingMaint?.length) && ( {!(alerts?.expiringCars?.length || alerts?.expiringDrivers?.length || alerts?.upcomingMaint?.length) && (
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>لا توجد تنبيهات حالياً.</p> <p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد تنبيهات حالياً.</p>
)} )}
</div> </div>
</article> </article>
@@ -184,21 +199,29 @@ export default function DashboardPage() {
{/* ── Security + endpoints ── */} {/* ── Security + endpoints ── */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}> <article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD" }}>الأمان</p> <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD", textAlign: "start" }}>الأمان</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>لمحة الحساب</h2> <h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>لمحة الحساب</h2>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}> <div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)" }}> <div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
آخر تسجيل دخول: {data?.accountSecurity?.lastLogin || "—"} آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
</div> </div>
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)" }}> <div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
بيانات الجهاز: {data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"} {/*
JSON.stringify output is structurally LTR (braces,
colons, commas read left-to-right regardless of
locale). Wrapping it in .ltr-embed prevents the
surrounding RTL paragraph from reordering the
punctuation — without this, nested objects can
render with their braces visually swapped.
*/}
بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span>
</div> </div>
</div> </div>
</article> </article>
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}> <article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7" }}>خريطة الخلفية</p> <p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7", textAlign: "start" }}>خريطة الخلفية</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>النقاط النهائية</h2> <h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>النقاط النهائية</h2>
<ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}> <ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
{[ {[
"/v1/dashboard/summary — نظرة عامة تشغيلية", "/v1/dashboard/summary — نظرة عامة تشغيلية",
@@ -206,8 +229,18 @@ export default function DashboardPage() {
"/v1/orders — دورة شحن الطلبات", "/v1/orders — دورة شحن الطلبات",
"/v1/trip — تنظيم الرحلات", "/v1/trip — تنظيم الرحلات",
].map((ep) => ( ].map((ep) => (
<li key={ep} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-dark-muted)" }}> <li key={ep} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
{ep} {/*
Each entry mixes an LTR API path with an Arabic
description. Splitting on the em-dash and
isolating only the path keeps the route string
from reading backwards while letting the Arabic
half flow naturally in the paragraph's own
direction.
*/}
<span className="ltr-embed">{ep.split(" — ")[0]}</span>
{" — "}
{ep.split(" — ")[1]}
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -0,0 +1,238 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI";
import { RoleToast } from "@/src/Components/role/RoleToast";
import { useRoles } from "@/src/hooks/useRole";
import type { Role, Permission } from "@/src/services/role.service";
// ── Module label map ───────────────────────────────────────────────────────
const MODULE_LABELS: Record<string, string> = {
Role: "الأدوار", User: "المستخدمون", Branch: "الفروع",
Car: "المركبات", CarImage: "صور المركبات", Driver: "السائقون",
Order: "الطلبات", Trip: "الرحلات", Client: "العملاء",
Permission: "الصلاحيات", Audit: "سجل التدقيق",
Dashboard: "لوحة التحكم", Maintenance: "الصيانة",
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
function groupByModule(permissions: Permission[]): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
(acc[p.module || "أخرى"] ??= []).push(p);
return acc;
}, {});
}
// ── Permission Matrix ──────────────────────────────────────────────────────
function PermissionMatrix({
role,
allPermissions,
onSave,
}: {
role: Role;
allPermissions: Permission[];
onSave: (roleId: string, permIds: string[]) => Promise<boolean>;
}) {
const currentIds = role.permissions?.map(rp => rp.permission.id) ?? [];
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const grouped = groupByModule(allPermissions);
const toggle = (id: string) => {
setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
setSaved(false);
};
const toggleModule = (mod: string) => {
const ids = (grouped[mod] ?? []).map(p => p.id);
const allChecked = ids.every(id => selected.has(id));
setSelected(prev => {
const next = new Set(prev);
allChecked ? ids.forEach(id => next.delete(id)) : ids.forEach(id => next.add(id));
return next;
});
setSaved(false);
};
const handleSave = async () => {
setSaving(true);
const ok = await onSave(role.id, [...selected]);
setSaving(false);
if (ok) setSaved(true);
};
return (
<div style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", overflow: "hidden",
}}>
{/* Role header */}
<div style={{
padding: "1rem 1.5rem", borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div>
<p style={{ fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
{role.description && (
<p style={{ fontSize: 12, color: "var(--color-text-muted)", marginTop: 2 }}>{role.description}</p>
)}
<p style={{ fontSize: 11, color: "var(--color-text-hint)", marginTop: 4 }}>
{selected.size} من {allPermissions.length} صلاحية مفعّلة
</p>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{saved && (
<span style={{ fontSize: 12, color: "#166534", fontWeight: 600 }}> تم الحفظ</span>
)}
<button type="button" onClick={handleSave} disabled={saving}
style={{
height: 36, padding: "0 1.25rem", 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 ? "جارٍ الحفظ…" : "حفظ"}
</button>
</div>
</div>
{/* Module rows */}
<div style={{ padding: "0.75rem" }}>
{Object.entries(grouped).map(([mod, perms]) => {
const allChecked = perms.every(p => selected.has(p.id));
const someChecked = !allChecked && perms.some(p => selected.has(p.id));
return (
<div key={mod} style={{ marginBottom: "0.5rem" }}>
{/* Module header */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.5rem 0.75rem",
background: "var(--color-surface-muted)",
borderRadius: "var(--radius-md)",
marginBottom: "0.375rem",
}}>
<input type="checkbox" checked={allChecked}
ref={el => { if (el) el.indeterminate = someChecked; }}
onChange={() => toggleModule(mod)}
style={{ width: 15, height: 15, cursor: "pointer", accentColor: "#2563EB" }} />
<span style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-primary)" }}>
{moduleLabel(mod)}
</span>
<span style={{ fontSize: 10, color: "var(--color-text-muted)", marginRight: "auto" }}>
{perms.filter(p => selected.has(p.id)).length}/{perms.length}
</span>
</div>
{/* Permissions */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.375rem", paddingRight: "1.5rem" }}>
{perms.map(perm => {
const checked = selected.has(perm.id);
return (
<label key={perm.id} style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "0.3rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked ? "#EFF6FF" : "var(--color-surface-muted)",
cursor: "pointer", userSelect: "none",
}}>
<input type="checkbox" checked={checked} onChange={() => toggle(perm.id)}
style={{ width: 13, height: 13, cursor: "pointer", accentColor: "#2563EB" }} />
<span style={{ fontSize: 11, fontWeight: 600, color: checked ? "#1D4ED8" : "var(--color-text-primary)" }}>
{perm.name}
</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function RolePermissionPage() {
const { roles, loading, error, permissions, clearError, updateRolePermissionsBulk, notification } = useRoles();
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const selectedRole = roles.find(r => r.id === selectedRoleId) ?? null;
const handleSave = useCallback(async (roleId: string, permIds: string[]): Promise<boolean> => {
return updateRolePermissionsBulk(roleId, permIds);
}, [updateRolePermissionsBulk]);
return (
<>
<RoleToast notification={notification} />
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* 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)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الصلاحيات
</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 }}>
مصفوفة الصلاحيات
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إدارة صلاحيات كل دور بشكل مجمَّع
</p>
</div>
<div style={{ minWidth: 240 }}>
<select value={selectedRoleId} onChange={e => setSelectedRoleId(e.target.value)} dir="rtl"
style={{ width: "100%", height: 40, padding: "0 0.75rem", borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)" }}>
<option value="">اختر دوراً لتعديل صلاحياته</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
</div>
</header>
{error && <Alert type="error" message={error} onClose={clearError} />}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : !selectedRole ? (
<div style={{
borderRadius: "var(--radius-xl)", border: "2px dashed var(--color-border)",
background: "var(--color-surface)", padding: "4rem 2rem", textAlign: "center",
}}>
<p style={{ fontSize: 32, margin: 0 }}>🔐</p>
<p style={{ marginTop: 12, fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)" }}>
اختر دوراً من القائمة أعلاه
</p>
<p style={{ marginTop: 4, fontSize: 13, color: "var(--color-text-muted)" }}>
ستظهر هنا صلاحيات الدور المحدد وبإمكانك تعديلها مباشرة.
</p>
</div>
) : (
<PermissionMatrix
key={selectedRole.id}
role={selectedRole}
allPermissions={permissions}
onSave={handleSave}
/>
)}
</section>
</>
);
}

View File

@@ -1,3 +1,143 @@
"use client";
import { useState } from "react";
import { Alert } from "@/src/Components/UI";
import { RoleTable } from "@/src/Components/role/RoleTable";
import { RoleFormModal } from "@/src/Components/role/RoleFormModal";
import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal";
import { DeleteRoleModal } from "@/src/Components/role/DeleteRoleModal";
import { RoleToast } from "@/src/Components/role/RoleToast";
import { useRoles } from "@/src/hooks/useRole";
import type { Role, RoleFormData } from "@/src/services/role.service";
export default function RolesPage() { export default function RolesPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Roles and permissions module is ready for RBAC assignment and matrix views.</section>; const {
roles, loading, total, pages, error,
permissions,
page, search,
setPage, handleSearch, clearError,
createRole, updateRole, deleteRole,
updateRolePermissionsBulk,
notification,
} = useRoles();
// false = closed | null = create mode | Role = edit mode
const [formTarget, setFormTarget] = useState<Role | null | false>(false);
const [viewTarget, setViewTarget] = useState<Role | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null);
const [deleting, setDeleting] = useState(false);
const handleFormSubmit = async (
data: RoleFormData,
currentPermIds: string[],
): Promise<boolean> => {
if (formTarget === null) {
return createRole(data);
}
return updateRole((formTarget as Role).id, data, currentPermIds);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return;
setDeleting(true);
const ok = await deleteRole(deleteTarget.id);
setDeleting(false);
if (ok) setDeleteTarget(null);
};
const handleSavePermissions = async (
roleId: string,
permissionIds: string[],
): Promise<boolean> => {
return updateRolePermissionsBulk(roleId, permissionIds);
};
return (
<>
<RoleToast notification={notification} />
{formTarget !== false && (
<RoleFormModal
editRole={formTarget}
permissions={permissions}
onClose={() => setFormTarget(false)}
onSubmit={handleFormSubmit}
/>
)}
{viewTarget && (
<RoleDetailModal
role={viewTarget}
allPermissions={permissions}
onClose={() => setViewTarget(null)}
onSave={handleSavePermissions}
/>
)}
{deleteTarget && (
<DeleteRoleModal
role={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* 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)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الصلاحيات
</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 }}>
الأدوار والصلاحيات
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> دور
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ position: "relative", width: 256 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input type="text" placeholder="بحث بالاسم…" value={search}
onChange={e => handleSearch(e.target.value)} dir="rtl"
style={{ width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)" }} />
</div>
<button type="button" onClick={() => setFormTarget(null)}
style={{ height: 40, padding: "0 1.125rem", borderRadius: "var(--radius-lg)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 7, fontFamily: "var(--font-sans)", boxShadow: "0 1px 4px rgba(37,99,235,.35)", whiteSpace: "nowrap" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة دور
</button>
</div>
</div>
</header>
{error && <Alert type="error" message={error} onClose={clearError} />}
<RoleTable
roles={roles}
loading={loading}
search={search}
page={page}
pages={pages}
onView={role => setViewTarget(role)}
onEdit={role => setFormTarget(role)}
onDelete={role => setDeleteTarget(role)}
onAddFirst={() => setFormTarget(null)}
onPageChange={setPage}
/>
</section>
</>
);
} }

View File

@@ -1,19 +1,38 @@
"use client"; "use client";
import { useCallback, useEffect, 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 "@/Components/UI"; import { Spinner } from "@/src/Components/UI";
import { TripFormModal } from "@/Components/Trip/Tripformmodal"; import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
import { TripDeleteModal } from "@/Components/Trip/Tripdeletemodal"; import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
import { TripReportPanel } from "@/Components/Trip_Report/Tripreportpanel"; import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
import { tripService } from "@/services/trip.service"; import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import type { import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
Trip, import { TRIP_STATUS_MAP } from "@/src/types/trip";
CreateTripPayload, import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
UpdateTripPayload,
} from "@/types/trip"; // ── API error extractor ────────────────────────────────────────────────────
import { TRIP_STATUS_MAP } from "@/types/trip"; // Same shape-walking logic as useTrip.ts — duplicated here on purpose so this
// page no longer depends on the list hook for a single piece of UI feedback.
function extractApiMessage(err: unknown, fallback: string): string {
if (typeof err === "string" && err.trim()) return err.trim();
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
const responseData = (e["response"] as Record<string, unknown> | undefined)?.["data"];
if (responseData && typeof responseData === "object") {
const rd = responseData as Record<string, unknown>;
if (typeof rd["message"] === "string" && rd["message"].trim()) return rd["message"];
if (Array.isArray(rd["message"])) return (rd["message"] as string[]).join(" — ");
if (typeof rd["error"] === "string" && rd["error"].trim()) return rd["error"];
}
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"];
}
return fallback;
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -23,19 +42,26 @@ function fmtDate(iso?: string | null): string {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "2-digit",
minute: "2-digit",
}); });
} }
function fmtNumber(n?: number | string | null): string { function fmtDateTime(iso?: string | null): string {
if (n == null || n === "") return "—"; if (!iso) return "—";
return Number(n).toLocaleString("ar-SA"); return new Date(iso).toLocaleString("ar-SA", {
dateStyle: "medium",
timeStyle: "short",
});
} }
// ── Sub-components ──────────────────────────────────────────────────────────── // ── Sub-components ────────────────────────────────────────────────────────────
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) { function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return ( return (
<div <div
style={{ style={{
@@ -46,19 +72,23 @@ function SectionCard({ title, children }: { title: string; children: React.React
boxShadow: "var(--shadow-card)", boxShadow: "var(--shadow-card)",
}} }}
> >
<div style={{ <div
padding: "0.875rem 1.5rem", style={{
borderBottom: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
background: "var(--color-surface-muted)", borderBottom: "1px solid var(--color-border)",
}}> background: "var(--color-surface-muted)",
<p style={{ }}
fontSize: 11, >
letterSpacing: "0.25em", <p
textTransform: "uppercase", style={{
color: "var(--color-text-hint)", fontSize: 11,
fontWeight: 700, letterSpacing: "0.25em",
margin: 0, textTransform: "uppercase",
}}> color: "var(--color-text-hint)",
fontWeight: 700,
margin: 0,
}}
>
{title} {title}
</p> </p>
</div> </div>
@@ -71,63 +101,70 @@ function DetailRow({
label, label,
value, value,
mono = false, mono = false,
warn = false,
}: { }: {
label: string; label: string;
value: string; value: string;
mono?: boolean; mono?: boolean;
warn?: boolean;
}) { }) {
return ( return (
<div style={{ <div
display: "flex", style={{
justifyContent: "space-between", display: "flex",
alignItems: "baseline", justifyContent: "space-between",
padding: "0.55rem 0", alignItems: "baseline",
borderBottom: "1px solid var(--color-border)", padding: "0.55rem 0",
}}> borderBottom: "1px solid var(--color-border)",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}> <span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
{label} {label}
</span> </span>
<span style={{ <span
fontSize: 13, style={{
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", fontSize: 13,
color: "var(--color-text-primary)", fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
maxWidth: "60%", color: warn ? "#D97706" : "var(--color-text-primary)",
textAlign: "left", fontWeight: warn ? 600 : 400,
wordBreak: "break-word", maxWidth: "60%",
}}> textAlign: "left",
wordBreak: "break-word",
}}
>
{warn && value !== "—" ? "⚠ " : ""}
{value} {value}
</span> </span>
</div> </div>
); );
} }
// ── Stat card (للأعداد) ───────────────────────────────────────────────────────
function StatCard({ function StatCard({
label, label,
value, value,
color = "var(--color-brand-600)", color,
bg = "var(--color-brand-50, #EFF6FF)",
}: { }: {
label: string; label: string;
value: string; value: number | string;
color?: string; color: string;
bg?: string;
}) { }) {
return ( return (
<div style={{ <div
borderRadius: "var(--radius-lg)", style={{
border: "1px solid var(--color-border)", padding: "1rem 1.25rem",
background: bg, background: "var(--color-surface)",
padding: "1rem 1.25rem", border: "1px solid var(--color-border)",
display: "flex", borderRadius: "var(--radius-lg)",
flexDirection: "column", boxShadow: "var(--shadow-card)",
gap: 4, display: "flex",
}}> flexDirection: "column",
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)" }}> gap: 4,
}}
>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", letterSpacing: "0.05em" }}>
{label} {label}
</span> </span>
<span style={{ fontSize: 22, fontWeight: 700, color, fontFamily: "var(--font-mono)" }}> <span style={{ fontSize: 22, fontWeight: 800, color, fontFamily: "var(--font-mono)" }}>
{value} {value}
</span> </span>
</div> </div>
@@ -141,14 +178,31 @@ export default function TripDetailPage() {
const router = useRouter(); const router = useRouter();
const tripId = params?.tripId as string; const tripId = params?.tripId as string;
const [trip, setTrip] = useState<Trip | null>(null); const [trip, setTrip] = useState<Trip | 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);
// ── Modal state ─────────────────────────────────────────────────────────── // ── Modal state ───────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// ── Notifications (standalone — no longer borrowed from the list hook) ───
const [notification, setNotification] = useState<ToastNotification | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n);
timerRef.current = setTimeout(() => setNotification(null), 4000);
}, []);
const dismissNotification = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(null);
}, []);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// ── Load trip ───────────────────────────────────────────────────────────── // ── Load trip ─────────────────────────────────────────────────────────────
const loadTrip = useCallback(async () => { const loadTrip = useCallback(async () => {
@@ -166,7 +220,9 @@ export default function TripDetailPage() {
} }
}, [tripId]); }, [tripId]);
useEffect(() => { loadTrip(); }, [loadTrip]); useEffect(() => {
loadTrip();
}, [loadTrip]);
// ── Edit submit ─────────────────────────────────────────────────────────── // ── Edit submit ───────────────────────────────────────────────────────────
const handleEditSubmit = useCallback( const handleEditSubmit = useCallback(
@@ -177,299 +233,317 @@ export default function TripDetailPage() {
if (!trip) return false; if (!trip) return false;
try { try {
const token = getStoredToken(); const token = getStoredToken();
await tripService.update(trip.id, payload as UpdateTripPayload, token); const res = await tripService.update(trip.id, payload as UpdateTripPayload, token);
await loadTrip(); const updated = (res as unknown as { data: Trip }).data;
setTrip(updated);
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
return true; return true;
} catch { } catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
return false; return false;
} }
}, },
[trip, loadTrip], [trip, notify],
); );
// ── Delete ──────────────────────────────────────────────────────────────── // ── Delete confirm ────────────────────────────────────────────────────────
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {
if (!trip) return; if (!trip) return;
setDeleting(true); setDeleting(true);
try { try {
const token = getStoredToken(); const token = getStoredToken();
await tripService.delete(trip.id, token); await tripService.delete(trip.id, token);
router.back(); router.push("/dashboard/trips");
} catch { } catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") });
setDeleting(false); setDeleting(false);
setDeleteOpen(false);
} }
}, [trip, router]); }, [trip, router, notify]);
// ── Status config ─────────────────────────────────────────────────────────
const statusConfig = trip ? TRIP_STATUS_MAP[trip.status] : null; const statusConfig = trip ? TRIP_STATUS_MAP[trip.status] : null;
// ── Loading ─────────────────────────────────────────────────────────────── // ── Render: Loading ───────────────────────────────────────────────────────
if (loading) { if (loading) {
return ( return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}> <div
<Spinner size="lg" /> style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "6rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ التحميل</span>
</div> </div>
); );
} }
// ── Error ───────────────────────────────────────────────────────────────── // ── Render: Error ─────────────────────────────────────────────────────────
if (error || !trip) { if (error || !trip) {
return ( return (
<div style={{ padding: "2rem", textAlign: "center" }}> <div
<p style={{ fontSize: 14, color: "var(--color-danger)", marginBottom: "1rem" }}> style={{
{error ?? "الرحلة غير موجودة."} maxWidth: 480,
margin: "4rem auto",
borderRadius: "var(--radius-xl)",
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "1.5rem",
textAlign: "center",
}}
>
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
{error ?? "الرحلة غير موجودة"}
</p> </p>
<button <button
type="button" type="button"
onClick={() => router.back()} onClick={() => router.back()}
style={{ style={{
height: 38, padding: "0 1.25rem", marginTop: "1rem",
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
fontSize: 13, fontWeight: 600, cursor: "pointer", fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer",
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
العودة رجوع
</button> </button>
</div> </div>
); );
} }
// ── Render: Trip detail ────────────────────────────────────────────────────
return ( return (
<> <>
<section style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.5rem" }}> {/* ── Toast notification ── */}
<Toast notification={notification} onDismiss={dismissNotification} />
{/* ── Header ── */} <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
<header style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}>
{/* Back + trip info */}
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<button
type="button"
onClick={() => router.back()}
style={{
width: 38, height: 38, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", flexShrink: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{/* Trip icon */} {/* ── Page header ── */}
<div style={{ <header
width: 52, height: 52, borderRadius: "var(--radius-lg)", style={{
background: "var(--color-brand-50, #EFF6FF)", borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-brand-200, #BFDBFE)", border: "1px solid var(--color-border)",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, background: "var(--color-surface)",
}}> padding: "1.5rem 2rem",
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563EB" strokeWidth="1.8"> boxShadow: "var(--shadow-card)",
<rect x="1" y="3" width="15" height="13" rx="2" /> }}
<path d="M16 8h4l3 5v4h-7V8z" /> >
<circle cx="5.5" cy="18.5" r="2.5" /> {/* Back link */}
<circle cx="18.5" cy="18.5" r="2.5" /> <button
</svg> type="button"
</div> onClick={() => router.back()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
marginBottom: "1rem",
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
العودة إلى قائمة الرحلات
</button>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}> {/* Icon + title */}
{trip.title} <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
</h1> <div
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}> style={{
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}> width: 72,
{trip.tripNumber} height: 72,
borderRadius: "50%",
overflow: "hidden",
flexShrink: 0,
border: "2px solid var(--color-brand-200)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>
{trip.title.charAt(0)}
</span> </span>
{statusConfig && ( </div>
<span style={{
borderRadius: "var(--radius-full)", <div>
border: `1px solid ${statusConfig.border}`, <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
background: statusConfig.bg, ملف الرحلة
padding: "0.2rem 0.625rem", </p>
fontSize: 11, <h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
fontWeight: 700, {trip.title}
color: statusConfig.color, </h1>
display: "inline-flex", <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
alignItems: "center", <span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
gap: 5, #{trip.tripNumber}
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
{statusConfig.label}
</span> </span>
)} {statusConfig && (
{trip.isDeleted && ( <span
<span style={{ style={{
borderRadius: "var(--radius-full)", borderRadius: "var(--radius-full)",
border: "1px solid #FECACA", border: `1px solid ${statusConfig.border}`,
background: "#FEF2F2", background: statusConfig.bg,
padding: "0.2rem 0.625rem", padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: "#DC2626", fontSize: 11,
}}> fontWeight: 700,
محذوفة color: statusConfig.color,
</span> display: "inline-flex",
)} alignItems: "center",
gap: 5,
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
{statusConfig.label}
</span>
)}
</div>
</div> </div>
</div> </div>
</div>
{/* Actions */} {/* Actions */}
<div style={{ display: "flex", gap: "0.75rem" }}> <div style={{ display: "flex", gap: "0.75rem" }}>
<button <button
type="button" type="button"
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
style={{ style={{
height: 40, padding: "0 1.25rem", height: 40,
borderRadius: "var(--radius-lg)", padding: "0 1.25rem",
border: "1px solid #FECACA", borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, fontWeight: 700, color: "#DC2626", background: "#FEF2F2",
cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 13,
}} fontWeight: 700,
> color: "#DC2626",
حذف الرحلة cursor: "pointer",
</button> fontFamily: "var(--font-sans)",
<button }}
type="button" >
onClick={() => setEditOpen(true)} حذف الرحلة
style={{ </button>
height: 40, padding: "0 1.25rem", <button
borderRadius: "var(--radius-lg)", type="button"
border: "none", onClick={() => setEditOpen(true)}
background: "var(--color-brand-600)", style={{
fontSize: 13, fontWeight: 700, color: "#FFF", height: 40,
cursor: "pointer", padding: "0 1.25rem",
display: "flex", alignItems: "center", gap: 8, borderRadius: "var(--radius-lg)",
fontFamily: "var(--font-sans)", border: "none",
}} background: "var(--color-brand-600)",
> fontSize: 13,
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> fontWeight: 700,
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> color: "#FFF",
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> cursor: "pointer",
</svg> display: "flex",
تعديل الرحلة alignItems: "center",
</button> gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<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>
</div>
</div> </div>
</header> </header>
{/* ── Stats row ── */} {/* ── Stats row ── */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "1rem" }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: "0.75rem" }}>
<StatCard <StatCard label="المجمّع" value={trip.collectedCount} color="var(--color-brand-600)" />
label="المجمّع" <StatCard label="المُسلَّم" value={trip.deliveredCount} color="#16A34A" />
value={String(trip.collectedCount)} <StatCard label="المُرتجع" value={trip.returnedCount} color="#D97706" />
color="#1E40AF"
bg="#EFF6FF"
/>
<StatCard
label="المُسلَّم"
value={String(trip.deliveredCount)}
color="#166534"
bg="#DCFCE7"
/>
<StatCard
label="المُرتجع"
value={String(trip.returnedCount)}
color="#92400E"
bg="#FFFBEB"
/>
<StatCard <StatCard
label="النقد المحصّل" label="النقد المحصّل"
value={fmtNumber(trip.totalCashCollected)} value={trip.totalCashCollected != null ? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س` : "—"}
color="#581C87" color="#7C3AED"
bg="#FAF5FF"
/> />
</div> </div>
{/* ── Content grid ── */} {/* ── Content grid ── */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}> <div
style={{
{/* Basic Info */} display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.5rem",
}}
>
{/* Trip Info */}
<SectionCard title="بيانات الرحلة"> <SectionCard title="بيانات الرحلة">
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono /> <DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} />
<DetailRow label="العنوان" value={trip.title} /> <DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} />
<DetailRow label="الحالة" value={statusConfig?.label ?? trip.status} />
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} /> <DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
<DetailRow label="وقت البدء" value={fmtDate(trip.startTime)} /> {trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />}
<DetailRow label="وقت الانتهاء" value={fmtDate(trip.endTime)} /> {trip.endReason && <DetailRow label="سبب الإنهاء" value={trip.endReason} warn />}
<DetailRow label="سبب الإنهاء" value={trip.endReason ?? "—"} />
</SectionCard> </SectionCard>
{/* Driver */} {/* Driver */}
<SectionCard title="السائق"> {trip.driver && (
{trip.driver ? ( <SectionCard title="بيانات السائق">
<> <DetailRow label="الاسم" value={trip.driver.name} />
<DetailRow label="الاسم" value={trip.driver.name} /> <DetailRow label="الجوال" value={trip.driver.phone} mono />
<DetailRow label="الجوال" value={trip.driver.phone} mono /> <DetailRow label="اسم المستخدم" value={trip.driver.userName ?? "—"} mono />
<DetailRow label="البريد" value={trip.driver.email ?? "—"} /> <DetailRow label="البريد" value={trip.driver.email ?? "—"} />
<DetailRow label="رقم الرخصة" value={trip.driver.licenseNumber ?? "—"} mono /> <DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} />
<DetailRow label=قم البطاقة" value={trip.driver.driverCardNumber ?? "—"} mono /> <DetailRow label=خصة القيادة" value={trip.driver.licenseNumber ?? "—"} mono />
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} /> <DetailRow label="بطاقة السائق" value={trip.driver.driverCardNumber ?? "—"} mono />
</> <DetailRow label="رقم GOSI" value={trip.driver.gosiNumber ?? "—"} mono />
) : ( </SectionCard>
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: 0 }}>لا توجد بيانات للسائق.</p> )}
)}
</SectionCard>
{/* Car */} {/* Car */}
<SectionCard title="السيارة"> {trip.car && (
{trip.car ? ( <SectionCard title="بيانات السيارة">
<> <DetailRow label="الشركة" value={trip.car.manufacturer} />
<DetailRow label="الماركة" value={trip.car.manufacturer} /> <DetailRow label="الموديل" value={trip.car.model} />
<DetailRow label="الموديل" value={trip.car.model} /> <DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} />
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} /> <DetailRow label="اللون" value={trip.car.color ?? "—"} />
<DetailRow label="اللون" value={trip.car.color ?? "—"} /> <DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono /> <DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono />
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono /> <DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono />
<DetailRow label="نوع اللوحة" value={trip.car.plateType ?? "—"} /> </SectionCard>
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono /> )}
</>
) : (
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: 0 }}>لا توجد بيانات للسيارة.</p>
)}
</SectionCard>
{/* System Info */} {/* System Info */}
<SectionCard title="معلومات النظام"> <SectionCard title="معلومات النظام">
<DetailRow label="تاريخ الإضافة" value={fmtDate(trip.createdAt)} /> <DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
<DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} /> <DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} />
{trip.isDeleted && (
<DetailRow label="تاريخ الحذف" value={fmtDate(trip.deletedAt)} />
)}
</SectionCard> </SectionCard>
</div> </div>
{/* ── Notes (full width, if any) ── */} {/* ── Report section (full width) ── */}
{trip.notes && ( <div
<SectionCard title="الملاحظات"> style={{
<p style={{ fontSize: 13, color: "var(--color-text-primary)", lineHeight: 1.7, margin: 0 }}> borderRadius: "var(--radius-xl)",
{trip.notes} border: "1px solid var(--color-border)",
</p> background: "var(--color-surface)",
</SectionCard> boxShadow: "var(--shadow-card)",
)} padding: "1.5rem",
}}
{/* ── Report section ── */} >
<div style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
}}>
<TripReportPanel tripId={trip.id} /> <TripReportPanel tripId={trip.id} />
</div> </div>

View File

@@ -1,3 +1,375 @@
export default function TripsPage() { "use client";
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Trips module is ready for scheduling, status tracking, and manifest generation.</section>;
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTrips } from "@/src/hooks/useTrip";
import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth";
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
import { Spinner } from "@/src/Components/UI";
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
import type { Trip, TripStatus, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
// ── API error extractor ────────────────────────────────────────────────────
// Same logic as the detail page / useTrip.ts — kept local so create/update
// here can notify independently of the list hook's own notification state.
function extractApiMessage(err: unknown, fallback: string): string {
if (typeof err === "string" && err.trim()) return err.trim();
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
const responseData = (e["response"] as Record<string, unknown> | undefined)?.["data"];
if (responseData && typeof responseData === "object") {
const rd = responseData as Record<string, unknown>;
if (typeof rd["message"] === "string" && rd["message"].trim()) return rd["message"];
if (Array.isArray(rd["message"])) return (rd["message"] as string[]).join(" — ");
if (typeof rd["error"] === "string" && rd["error"].trim()) return rd["error"];
}
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"];
}
return fallback;
}
// ── Status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: TripStatus }) {
const s = TRIP_STATUS_MAP[status];
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "3px 10px", borderRadius: 999,
fontSize: 11, fontWeight: 700, letterSpacing: "0.02em",
color: s.color, background: s.bg, border: `1px solid ${s.border}`,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
{s.label}
</span>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function TripsPage() {
const router = useRouter();
const {
trips, total, pages, loading, error,
page, setPage,
search, handleSearch,
status, handleStatusFilter,
reload,
} = useTrips();
// ── Notifications (standalone — fired explicitly by every action below) ──
const [notification, setNotification] = useState<ToastNotification | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n);
timerRef.current = setTimeout(() => setNotification(null), 4000);
}, []);
const dismissNotification = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(null);
}, []);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// Local input value is debounced before it reaches the hook's `search`,
// exactly like the table reloads only after the user stops typing.
const [searchInput, setSearchInput] = useState(search);
useEffect(() => {
const t = setTimeout(() => handleSearch(searchInput), 400);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
const [showForm, setShowForm] = useState(false);
const [editTrip, setEditTrip] = useState<Trip | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Trip | null>(null);
const [deleting, setDeleting] = useState(false);
// ── Create / update — calls tripService directly and notifies explicitly ──
const handleSubmit = async (
payload: CreateTripPayload | UpdateTripPayload,
isNew: boolean,
): Promise<boolean> => {
try {
const token = getStoredToken();
if (isNew) {
await tripService.create(payload as CreateTripPayload, token);
notify({ type: "success", message: "تم إضافة الرحلة بنجاح." });
} else {
await tripService.update(editTrip!.id, payload as UpdateTripPayload, token);
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
}
reload();
return true;
} catch (err) {
notify({
type: "error",
message: extractApiMessage(err, isNew ? "تعذّر إضافة الرحلة." : "تعذّر تحديث الرحلة."),
});
return false;
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const token = getStoredToken();
await tripService.delete(deleteTarget.id, token);
notify({ type: "success", message: "تم حذف الرحلة بنجاح." });
setDeleteTarget(null);
reload();
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") });
} finally {
setDeleting(false);
}
};
return (
<div dir="rtl" style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{/* ── Header ── */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "0.75rem" }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, color: "var(--color-text-primary)", margin: 0 }}>الرحلات</h1>
<p style={{ margin: "2px 0 0", fontSize: 12, color: "var(--color-text-muted)" }}>
إدارة جدولة الرحلات وتتبع حالتها
</p>
</div>
<button
onClick={() => { setEditTrip(null); setShowForm(true); }}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-md)", border: "none",
background: "var(--color-brand-600)", color: "#FFF",
fontSize: 13, fontWeight: 700, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<svg width="16" height="16" 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>
{/* ── Filters ── */}
<div style={{
display: "flex", gap: "0.75rem", flexWrap: "wrap",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
padding: "0.875rem 1rem",
}}>
<input
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
placeholder="بحث برقم الرحلة أو العنوان…"
dir="rtl"
style={{
flex: 1, minWidth: 200, height: 38, padding: "0 0.75rem",
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", fontSize: 13,
color: "var(--color-text-primary)", fontFamily: "var(--font-sans)", outline: "none",
}}
/>
<select
value={status}
onChange={e => handleStatusFilter(e.target.value as TripStatus | "")}
dir="rtl"
style={{
height: 38, padding: "0 0.75rem", minWidth: 140,
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", fontSize: 13,
color: "var(--color-text-primary)", fontFamily: "var(--font-sans)",
cursor: "pointer", outline: "none",
}}
>
<option value="">كل الحالات</option>
<option value="Scheduled">مجدولة</option>
<option value="InProgress">جارية</option>
<option value="Completed">مكتملة</option>
<option value="Cancelled">ملغاة</option>
</select>
</div>
{/* ── Table card ── */}
<div style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
overflow: "hidden",
boxShadow: "0 1px 4px rgba(0,0,0,.06)",
}}>
{loading ? (
<div style={{ padding: "3rem", display: "flex", justifyContent: "center" }}>
<Spinner size="md" />
</div>
) : error ? (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--color-danger)", fontSize: 13 }}>
{error}
</div>
) : trips.length === 0 ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--color-text-muted)", fontSize: 13 }}>
لا توجد رحلات مطابقة
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ background: "var(--color-surface-muted)", borderBottom: "1px solid var(--color-border)" }}>
{["رقم الرحلة", "العنوان", "السائق", "السيارة", "الحالة", "البدء", "الانتهاء", "إجراءات"].map(h => (
<th key={h} style={{
padding: "0.75rem 1rem", textAlign: "right", fontWeight: 700,
color: "var(--color-text-secondary)", fontSize: 11,
letterSpacing: "0.03em", whiteSpace: "nowrap",
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{trips.map((trip, i) => (
<tr
key={trip.id}
onClick={() => router.push(`/dashboard/trips/${trip.id}`)}
style={{
borderBottom: i < trips.length - 1 ? "1px solid var(--color-border)" : "none",
cursor: "pointer",
transition: "background 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "var(--color-surface-muted)")}
onMouseLeave={e => (e.currentTarget.style.background = "")}
>
<td style={{ padding: "0.875rem 1rem", whiteSpace: "nowrap" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-brand-600)", fontWeight: 700 }}>
{trip.tripNumber}
</span>
</td>
<td style={{ padding: "0.875rem 1rem", fontWeight: 600, color: "var(--color-text-primary)", maxWidth: 200 }}>
<span style={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{trip.title}
</span>
</td>
<td style={{ padding: "0.875rem 1rem", color: "var(--color-text-secondary)", whiteSpace: "nowrap" }}>
{trip.driver?.name ?? "—"}
</td>
<td style={{ padding: "0.875rem 1rem", color: "var(--color-text-secondary)", whiteSpace: "nowrap" }}>
{trip.car ? `${trip.car.manufacturer} ${trip.car.model}` : "—"}
</td>
<td style={{ padding: "0.875rem 1rem" }}>
<StatusBadge status={trip.status} />
</td>
<td style={{ padding: "0.875rem 1rem", color: "var(--color-text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
{trip.startTime ? new Date(trip.startTime).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" }) : "—"}
</td>
<td style={{ padding: "0.875rem 1rem", color: "var(--color-text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
{trip.endTime ? new Date(trip.endTime).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" }) : "—"}
</td>
<td style={{ padding: "0.875rem 1rem" }} onClick={e => e.stopPropagation()}>
<div style={{ display: "flex", gap: 6 }}>
<button
onClick={() => { setEditTrip(trip); setShowForm(true); }}
title="تعديل"
style={{
width: 30, height: 30, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)", background: "var(--color-surface)",
cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
color: "var(--color-text-muted)",
}}
>
<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
onClick={() => setDeleteTarget(trip)}
title="حذف"
style={{
width: 30, height: 30, borderRadius: "var(--radius-md)",
border: "1px solid #FECACA", background: "#FEF2F2",
cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
color: "#DC2626",
}}
>
<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 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div style={{
padding: "0.875rem 1rem",
borderTop: "1px solid var(--color-border)",
display: "flex", alignItems: "center", justifyContent: "space-between",
background: "var(--color-surface-muted)",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{total} رحلة صفحة {page} من {pages}
</span>
<div style={{ display: "flex", gap: 6 }}>
{[...Array(pages)].map((_, idx) => (
<button
key={idx}
onClick={() => setPage(idx + 1)}
style={{
width: 32, height: 32, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: page === idx + 1 ? "var(--color-brand-600)" : "var(--color-surface)",
color: page === idx + 1 ? "#FFF" : "var(--color-text-secondary)",
fontSize: 13, fontWeight: 600, cursor: "pointer",
}}
>
{idx + 1}
</button>
))}
</div>
</div>
)}
</div>
{/* ── Modals ── */}
{showForm && (
<TripFormModal
editTrip={editTrip}
onClose={() => setShowForm(false)}
onSubmit={handleSubmit}
/>
)}
{deleteTarget && (
<TripDeleteModal
trip={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
/>
)}
{/* ── Toast ── */}
<Toast notification={notification} onDismiss={dismissNotification} />
</div>
);
} }

View File

@@ -1,14 +1,13 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Alert } from "@/Components/UI"; import { Alert, Toast } from "@/src/Components/UI";
import { UserTable } from "@/Components/User/UserTable"; import { UserTable } from "@/src/Components/User/UserTable";
import { UserFormModal } from "@/Components/User/UserFormModal"; import { UserFormModal } from "@/src/Components/User/UserFormModal";
import { UserDetailModal } from "@/Components/User/UserDetailModal"; import { UserDetailModal } from "@/src/Components/User/UserDetailModal";
import { DeleteConfirmModal } from "@/Components/User/DeleteConfirmModal"; import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal";
import { Toast } from "@/Components/User/Toast"; import { useUsers } from "@/src/hooks/useUser";
import { useUsers } from "@/hooks/useUser"; import type { User, UserFormData } from "@/src/types/user";
import type { User, UserFormData } from "@/types/user";
export default function UsersPage() { export default function UsersPage() {
// ── Modal state ───────────────────────────────────────────────────────────── // ── Modal state ─────────────────────────────────────────────────────────────
@@ -41,9 +40,9 @@ export default function UsersPage() {
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return; if (!deleteTarget || deleting) return;
setDeleting(true); setDeleting(true);
console.log(setDeleting(true))
const ok = await deleteUser(deleteTarget.id); const ok = await deleteUser(deleteTarget.id);
console.log(ok)
setDeleting(false); setDeleting(false);
if (ok) setDeleteTarget(null); if (ok) setDeleteTarget(null);
}; };

View File

@@ -1,12 +1,61 @@
import Link from "next/link";
export default function ForbiddenPage() { export default function ForbiddenPage() {
return ( return (
<main className="min-h-screen bg-[linear-gradient(135deg,#020617_0%,#111827_45%,#172554_100%)] text-slate-100 flex items-center justify-center px-6 py-10"> <main
<section className="w-full max-w-xl rounded-3xl border border-rose-400/20 bg-slate-900/85 p-8 shadow-2xl shadow-slate-950/30 text-center"> dir="rtl"
<p className="text-sm uppercase tracking-[0.35em] text-rose-200">Access denied</p> style={{
<h1 className="mt-4 text-3xl font-semibold text-white">You do not have permission to open this section.</h1> minHeight: "100vh",
<p className="mt-3 text-slate-300">Contact an administrator to request access to the required module.</p> background: "var(--color-surface-muted)",
<a href="/dashboard" className="mt-6 inline-flex rounded-full bg-cyan-400 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-300">Return to dashboard</a> display: "flex",
</section> alignItems: "center",
justifyContent: "center",
padding: "2rem",
fontFamily: "var(--font-sans)",
}}
>
<div style={{ textAlign: "center", maxWidth: 480 }}>
<div style={{
width: 80, height: 80, margin: "0 auto",
borderRadius: "50%", background: "#FEF2F2",
border: "2px solid #FECACA",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="1.5">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p style={{
fontSize: "3rem", fontWeight: 800,
color: "#DC2626", lineHeight: 1,
margin: "1rem 0 0", fontFamily: "var(--font-mono)",
}}>403</p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", marginTop: "0.75rem" }}>
غير مصرح بالوصول
</h1>
<p style={{ fontSize: 14, color: "var(--color-text-muted)", marginTop: "0.75rem", lineHeight: 1.7 }}>
ليس لديك الصلاحية للوصول إلى هذه الصفحة. إذا كنت تعتقد أن هذا خطأ، تواصل مع مسؤول النظام.
</p>
<div style={{ marginTop: "2rem", display: "flex", gap: "0.75rem", justifyContent: "center" }}>
<Link href="/login"
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
textDecoration: "none",
display: "inline-flex", alignItems: "center",
}}>
تسجيل الدخول بحساب آخر
</Link>
</div>
</div>
</main> </main>
); );
} }

View File

@@ -1,4 +1,4 @@
@import "../styles/tokens.css"; @import "../src/styles/tokens.css";
@import "tailwindcss"; @import "tailwindcss";
@@ -12,7 +12,11 @@
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
font-size: 14px; font-size: 14px;
/* RTL is set per route-group layout, not globally */ /* `dir` is now set on <html> in app/layout.tsx (lang="ar" dir="rtl").
Tailwind v4 ships native `rtl:` / `ltr:` variants that key off this
attribute, so utility classes like `rtl:flex-row-reverse` work
without a plugin. Route groups no longer need to set `dir`
individually — see report Section 1. */
} }
body { body {
@@ -85,7 +89,17 @@ body {
} }
/* ── Focus ring utility ─────────────────────────────── */ /* ── Focus ring utility ─────────────────────────────── */
/* Unchanged: box-shadow rings are direction-neutral. */
.focus-ring { .focus-ring {
outline: none; outline: none;
box-shadow: 0 0 0 2px var(--color-brand-600); box-shadow: 0 0 0 2px var(--color-brand-600);
} }
/* ── RTL icon mirroring ─────────────────────────────────
Directional glyphs (chevrons, arrows, "back") need to visually
point the opposite way in RTL even though the underlying icon
font glyph doesn't change. Apply .icon-flip-rtl to any <i>/<svg>
whose meaning is "forward"/"back" rather than purely decorative. */
[dir="rtl"] .icon-flip-rtl {
transform: scaleX(-1);
}

View File

@@ -1,32 +1,33 @@
// app/layout.tsx
// Root layout — single location for font loading and global metadata.
// No inline styles, no @import in CSS.
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Plus_Jakarta_Sans, IBM_Plex_Mono } from "next/font/google"; import "@tabler/icons-webfont/dist/tabler-icons.min.css";
import "./globals.css"; import "./globals.css";
const jakartaSans = Plus_Jakarta_Sans({ /**
subsets: ["latin"], * NOT SHOWN IN THE ORIGINAL FILE SET.
weight: ["400", "500", "600", "700"], * This is the one file every RTL conversion ultimately hinges on:
variable: "--font-sans", * `dir="rtl"` and `lang="ar"` belong on the root <html> element so
display: "swap", * (a) the browser's native bidi algorithm kicks in everywhere,
}); * (b) Tailwind v4's `rtl:`/`ltr:` variants have something to key off,
* (c) form controls, scrollbars, and native widgets mirror correctly.
const ibmPlexMono = IBM_Plex_Mono({ *
subsets: ["latin"], * If your real app/layout.tsx differs (providers, fonts, etc.), keep
weight: ["400", "500"], * everything else as-is and only add the import below.
variable: "--font-mono", *
display: "swap", * ICON FONT: the project already uses Tabler Icons class names
}); * everywhere (ti ti-layout-dashboard, ti ti-car, ti ti-route, ...)
* but no stylesheet that defines those classes was ever loaded, so
* every icon in the sidebar was silently rendering as nothing.
* Self-hosted via `npm install @tabler/icons-webfont` rather than a
* CDN <link> — no external request at runtime, works behind
* corporate proxies / restrictive network policies, and Next bundles
* + fingerprints the CSS and font files automatically. Run
* `npm install @tabler/icons-webfont` if it isn't already a
* dependency, then this import resolves on its own.
*/
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: "لوحة التحكم",
default: "Slash.sa — Logistics Management", description: "نظام إدارة الأسطول والعمليات",
template: "%s | Slash.sa",
},
description:
"Modern logistics client portal — manage deliveries, drivers, and fleet operations.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -35,12 +36,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
// dir is set per route-group (ar = RTL, en = LTR) not globally <html lang="ar" dir="rtl">
<html <body className="app-shell">{children}</body>
lang="ar"
className={`${jakartaSans.variable} ${ibmPlexMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html> </html>
); );
} }

View File

@@ -3,11 +3,11 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { loginUser } from "../../lib/auth"; import { loginUser } from "@/src/lib/auth";
import Logo from "../../utils/logo"; import Logo from "@/src/utils/logo";
import { Button } from "../../Components/UI"; import { Button } from "@/src/Components/UI";
import { Input } from "../../Components/UI"; import { Input } from "@/src/Components/UI";
import { Alert } from "../../Components/UI"; import { Alert } from "@/src/Components/UI";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();

View File

@@ -1,7 +1,82 @@
import React from 'react' import Link from "next/link";
export default function page() { export default function NotFound() {
return ( return (
<div>page</div> <main
) dir="rtl"
style={{
minHeight: "100vh",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
fontFamily: "var(--font-sans)",
}}
>
<div style={{ textAlign: "center", maxWidth: 480 }}>
{/* Large 404 */}
<p style={{
fontSize: "6rem",
fontWeight: 800,
color: "var(--color-brand-600)",
lineHeight: 1,
margin: 0,
fontFamily: "var(--font-mono)",
}}>
404
</p>
<h1 style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
marginTop: "1rem",
}}>
الصفحة غير موجودة
</h1>
<p style={{
fontSize: 14,
color: "var(--color-text-muted)",
marginTop: "0.75rem",
lineHeight: 1.7,
}}>
الصفحة التي تبحث عنها غير موجودة أو ربما تم نقلها أو حذفها.
</p>
<div style={{ marginTop: "2rem", display: "flex", gap: "0.75rem", justifyContent: "center", flexWrap: "wrap" }}>
<Link href="/dashboard"
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
textDecoration: "none",
display: "inline-flex", alignItems: "center", gap: 8,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
لوحة التحكم
</Link>
<Link href="/"
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
textDecoration: "none",
display: "inline-flex", alignItems: "center",
}}>
الصفحة الرئيسية
</Link>
</div>
</div>
</main>
);
} }

View File

@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import Navbar from "./components/layout/Navbar"; import Navbar from "./components/layout/Navbar";
import Logo from "@/utils/logo"; import Logo from "@/src/utils/logo";
const stats = [ const stats = [
{ label: "الطلبات النشطة", value: "48", color: "text-blue-600" }, { label: "الطلبات النشطة", value: "48", color: "text-blue-600" },

View File

@@ -1,152 +0,0 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "../lib/auth";
import { driverService } from "../services/driver.service";
import type { Driver } from "../types/driver";
// ── Notification type ──────────────────────────────────────────────────────
export interface DriverNotification {
type: "success" | "error";
message: string;
}
// ── Table state / reducer ──────────────────────────────────────────────────
interface TableState {
drivers: Driver[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type TableAction =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; drivers: Driver[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "DELETE"; id: 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,
drivers: a.drivers,
total: a.total,
pages: a.pages,
};
case "LOAD_ERR":
return { ...s, loading: false, error: a.error };
case "DELETE":
return { ...s, drivers: s.drivers.filter((d) => d.id !== a.id) };
case "CLEAR_ERR":
return { ...s, error: null };
default:
return s;
}
}
const initialState: TableState = {
drivers: [],
loading: true,
total: 0,
pages: 1,
error: null,
};
// ── Main hook ──────────────────────────────────────────────────────────────
export function useDrivers() {
const [state, dispatch] = useReducer(reducer, initialState);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [notification, setNotification] =
useState<DriverNotification | null>(null);
/** Show a toast for 4 seconds then auto-dismiss. */
const notify = useCallback((n: DriverNotification) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
}, []);
// ── Fetch drivers ─────────────────────────────────────────────────────────
const loadDrivers = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const res = await driverService.getAll(p, q, token);
// The response shape from the backend:
// { data: { data: Driver[], pagination: { total, page, pages }, meta?: ... } }
const payload = (
res as unknown as {
data: {
data: Driver[];
pagination?: { total: number; pages: number };
meta?: { total: number; pages: number };
};
}
).data ?? res;
dispatch({
type: "LOAD_OK",
drivers: payload.data ?? [],
total:
payload.meta?.total ?? payload.pagination?.total ?? 0,
pages:
payload.meta?.pages ?? payload.pagination?.pages ?? 1,
});
} catch {
dispatch({
type: "LOAD_ERR",
error: "تعذّر تحميل بيانات السائقين. يرجى المحاولة مجدداً.",
});
}
}, []);
// Reload whenever page or search changes.
useEffect(() => {
loadDrivers(page, search);
}, [page, search, loadDrivers]);
// ── Delete ────────────────────────────────────────────────────────────────
const deleteDriver = useCallback(
async (id: string): Promise<boolean> => {
try {
const token = getStoredToken();
await driverService.delete(id, token);
dispatch({ type: "DELETE", id });
notify({ type: "success", message: "تم حذف السائق بنجاح." });
return true;
} catch (err) {
notify({
type: "error",
message:
err instanceof Error ? err.message : "تعذّر حذف السائق.",
});
return false;
}
},
[notify],
);
// ── Search helper ─────────────────────────────────────────────────────────
const handleSearch = useCallback((q: string) => {
setSearch(q);
setPage(1);
}, []);
return {
...state,
page,
search,
setPage,
handleSearch,
clearError: () => dispatch({ type: "CLEAR_ERR" }),
deleteDriver,
notification,
reload: () => loadDrivers(page, search),
};
}

View File

@@ -1,20 +0,0 @@
/**
* Format an ISO date string as a localised Arabic date.
* @example fmtDate("2026-06-08T10:00:00Z") → "٨ يونيو ٢٠٢٦"
*/
export function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
/**
* Format a numeric amount as Saudi Riyals.
* @example fmtAmount(1234.5) → "١٬٢٣٤٫٥٠ ر.س"
*/
export function fmtAmount(n: number): string {
return `${n.toFixed(2)} ر.س`;
}

View File

@@ -1,28 +0,0 @@
// lib/order-status.ts
// Order status display config — no React, no network.
import type { OrderStatus } from "../types/client";
export type { OrderStatus };
/** Tailwind class string for a status badge */
export function statusColor(status: OrderStatus): string {
const map: Record<OrderStatus, string> = {
CREATED: "bg-blue-50 text-blue-700 border-blue-200",
IN_TRANSIT: "bg-amber-50 text-amber-700 border-amber-200",
DELIVERED: "bg-emerald-50 text-emerald-700 border-emerald-200",
CANCELLED: "bg-red-50 text-red-600 border-red-200",
};
return map[status];
}
/** Arabic label for a status */
export function statusLabel(status: OrderStatus): string {
const map: Record<OrderStatus, string> = {
CREATED: "تم الإنشاء",
IN_TRANSIT: "قيد التوصيل",
DELIVERED: "تم التسليم",
CANCELLED: "ملغي",
};
return map[status];
}

View File

@@ -2,9 +2,15 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// No rewrites needed. All proxying is handled by images: {
// app/api/proxy/[...path]/route.ts, which reads the HttpOnly auth cookie remotePatterns: [
// server-side and forwards it as a Bearer token to the backend. {
protocol: "https",
hostname: "logiapi.slash.sa",
},
],
},
}; };
export default nextConfig; export default nextConfig;

2301
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@tabler/icons-webfont": "^3.44.0",
"next": "16.2.7", "next": "16.2.7",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",

View File

@@ -1,154 +0,0 @@
// services/driver.service.ts
// All API calls for the Driver module.
// Endpoints extracted from:
// - src/routes/driver.route.js → /api/v1/driver/...
// - src/routes/driver_report.route.js → /api/v1/drivers/:id/reports/daily
import { get, post, put, del, patch } from "./api";
import type {
Driver,
DriverListResponse,
DriverDetailResponse,
DriverReportResponse,
CreateDriverPayload,
UpdateDriverPayload,
} from "../types/driver";
/** Build a query string for list endpoints */
function buildQuery(
params: Record<string, string | number | undefined>,
): string {
const entries = Object.entries(params).filter(
([, v]) => v !== undefined && v !== "",
);
if (!entries.length) return "";
return (
"?" +
entries
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
.join("&")
);
}
export const driverService = {
/**
* GET /driver
* Fetch paginated + searchable driver list.
* Requires: "read-driver" permission
*/
getAll: (page = 1, search = "", token: string | null) =>
get<DriverListResponse>(
`driver${buildQuery({ page, limit: 12, search: search || undefined })}`,
token,
),
/**
* GET /driver/archived
* Fetch soft-deleted (archived) drivers.
* Requires: "read-deleted-driver" permission
*/
getArchived: (token: string | null) =>
get<DriverListResponse>("driver/archived", token),
/**
* GET /driver/me
* Fetch the currently authenticated driver's info.
* Requires: "read-driver" permission
*/
getMe: (token: string | null) =>
get<DriverDetailResponse>("driver/me", token),
/**
* GET /driver/:id
* Fetch a single driver with status history and branch info.
* Requires: "read-driver" permission
*/
getById: (id: string, token: string | null) =>
get<DriverDetailResponse>(`driver/${id}`, token),
/**
* POST /driver
* Create a new driver (also generates a random userName & password).
* Requires: "create-driver" permission
* Note: for file uploads (photo / nationalPhoto / driverCardPhoto) use uploadWithImages.
*/
create: (payload: CreateDriverPayload, token: string | null) =>
post<{ data: Driver }>("driver", payload, token),
/**
* PATCH /driver/:id
* Update driver data or status.
* Requires: "update-driver" permission
*/
update: (id: string, payload: UpdateDriverPayload, token: string | null) =>
patch<{ data: Driver }>(`driver/${id}`, payload, token),
/**
* DELETE /driver/:id
* Soft-delete a driver.
* Requires: "delete-driver" permission
*/
delete: (id: string, token: string | null) =>
del<void>(`driver/${id}`, token),
// ── Reports ───────────────────────────────────────────────────────────────
/**
* GET /driver/:id/reports/daily?date=YYYY-MM-DD
* Generate an HTML daily report for a driver on a specific date.
* Returns { reportUrl, filename }
* Requires: "generate-driver-report" permission
*/
getDailyReport: (id: string, date: string, token: string | null) =>
get<DriverReportResponse>(
`drivers/${id}/reports/daily?date=${encodeURIComponent(date)}`,
token,
),
// ── Image upload (multipart) ──────────────────────────────────────────────
/**
* POST /driver (multipart/form-data)
* Create a driver with photos attached.
* photo / nationalPhoto / driverCardPhoto are optional file fields.
*/
createWithImages: async (
payload: CreateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
token: string | null,
): Promise<{ data: Driver }> => {
const form = new FormData();
// Text fields
Object.entries(payload).forEach(([key, val]) => {
if (
val !== undefined &&
val !== null &&
!(val instanceof File)
) {
form.append(key, String(val));
}
});
// File fields
if (payload.photo) form.append("photo", payload.photo);
if (payload.nationalPhoto) form.append("nationalPhoto", payload.nationalPhoto);
if (payload.driverCardPhoto) form.append("driverCardPhoto", payload.driverCardPhoto);
const res = await fetch("/api/proxy/driver", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
cache: "no-store",
});
if (!res.ok) {
const json = await res.json().catch(() => null);
throw new Error(json?.message ?? `HTTP ${res.status}`);
}
return res.json() as Promise<{ data: Driver }>;
},
};

View File

@@ -1,7 +0,0 @@
export { request, get, post, put, del, ApiError } from "./api";
export { authService } from "./auth.service";
export { dashboardService } from "./dashboard.service";
export { clientService } from "./client.service";
export {userService} from "./user.service";
export { carService } from "./car.service";
export { driverService } from "./driver.service";

View File

@@ -6,7 +6,7 @@ import type {
ClientAddress, ClientAddress,
ClientAddressFormData, ClientAddressFormData,
ClientAddressFormErrors, ClientAddressFormErrors,
} from "../../types/client"; } from "@/src/types/client";
// ── Fixed styles ─────────────────────────────────────────────────────────── // ── Fixed styles ───────────────────────────────────────────────────────────
const S = { const S = {

View File

@@ -1,5 +1,5 @@
import React, { useState, FormEvent } from "react"; import React, { useState, FormEvent } from "react";
import { ClientAddress, CreateClientAddressPayload } from "../../types/client"; import { ClientAddress, CreateClientAddressPayload } from "@/src/types/client";
import { Input, Select, Button } from "../UI"; import { Input, Select, Button } from "../UI";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────

View File

@@ -1,5 +1,5 @@
import React, { useState, FormEvent } from "react"; import React, { useState, FormEvent } from "react";
import { Client, CreateClientPayload } from "../../types/client"; import { Client, CreateClientPayload } from "@/src/types/client";
import { Input, Textarea, Button } from "../UI"; import { Input, Textarea, Button } from "../UI";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Alert, Spinner } from "../UI"; import { Alert, Spinner } from "../UI";
import type { Client, ClientFormData, ClientFormErrors } from "../../types/client"; import type { Client, ClientFormData, ClientFormErrors } from "@/src/types/client";
// ── Fixed styles (same token system as UserFormModal) ────────────────────── // ── Fixed styles (same token system as UserFormModal) ──────────────────────
const S = { const S = {

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { Client } from "../../types/client"; import type { Client } from "@/src/types/client";
// ── Address count badge ──────────────────────────────────────────────────── // ── Address count badge ────────────────────────────────────────────────────
function AddressBadge({ count }: { count: number }) { function AddressBadge({ count }: { count: number }) {

View File

@@ -2,7 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { Client } from "../../types/client"; import type { Client } from "@/src/types/client";
interface DeleteConfirmModalProps { interface DeleteConfirmModalProps {
client: Client; client: Client;

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Notification } from "../../hooks/useClients"; import type { Notification } from "@/src/hooks/useClients";
interface ToastProps { interface ToastProps {
notification: Notification | null; notification: Notification | null;

View File

@@ -0,0 +1,5 @@
export { ClientFormModal } from "./Clientformmodal";
export { ClientTable } from "./Clienttable";
export { AddressFormModal } from "./Addressformmodal";
export { DeleteConfirmModal } from "./Deleteconfirmmodal";
export { Toast } from "./Toast";

View File

@@ -3,7 +3,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { Driver } from "../../types/driver"; import type { Driver } from "@/src/types/driver";
interface DriverDeleteModalProps { interface DriverDeleteModalProps {
driver: Driver; driver: Driver;

View File

@@ -2,15 +2,15 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import { driverService } from "../../services/driver.service"; import { driverService } from "@/src/services/driver.service";
import { getStoredToken } from "../../lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { DriverReportPanel } from "../Driver_Report/driverReport"; import { DriverReportPanel } from "../Driver_Report/driverReport";
import type { Driver } from "../../types/driver"; import type { Driver } from "@/src/types/driver";
import { import {
DRIVER_STATUS_MAP, DRIVER_STATUS_MAP,
DRIVER_CARD_TYPE_MAP, DRIVER_CARD_TYPE_MAP,
NATIONAL_ID_TYPE_MAP, NATIONAL_ID_TYPE_MAP,
} from "../../types/driver"; } from "@/src/types/driver";
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -25,8 +25,7 @@ function fmtDate(iso?: string | null): string {
function isExpiringSoon(iso?: string | null): boolean { function isExpiringSoon(iso?: string | null): boolean {
if (!iso) return false; if (!iso) return false;
const diff = new Date(iso).getTime() - Date.now(); return new Date(iso).getTime() - Date.now() <= 90 * 86_400_000;
return diff <= 90 * 86_400_000;
} }
// ── Sub-components ──────────────────────────────────────────────────────────── // ── Sub-components ────────────────────────────────────────────────────────────
@@ -90,17 +89,17 @@ function SectionHeading({ title }: { title: string }) {
); );
} }
// ── Photos section ──────────────────────────────────────────────────────────── // ── 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({ function PhotoCard({ url, label }: { url?: string | null; label: string }) {
url,
label,
}: {
url?: string | null;
label: string;
}) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
useEffect(() => {
setImgError(false);
}, [url]);
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}> <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)" }}> <span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)" }}>
@@ -122,6 +121,7 @@ function PhotoCard({
{url && !imgError ? ( {url && !imgError ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
key={url}
src={url} src={url}
alt={label} alt={label}
style={{ width: "100%", height: "100%", objectFit: "cover" }} style={{ width: "100%", height: "100%", objectFit: "cover" }}
@@ -181,9 +181,7 @@ export function DriverDetailPanel({
const [driver, setDriver] = useState<Driver | null>(null); const [driver, setDriver] = useState<Driver | 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 [avatarError, setAvatarError] = useState(false);
// Fetch driver data
const loadDriver = useCallback(async () => { const loadDriver = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -198,11 +196,8 @@ export function DriverDetailPanel({
} }
}, [driverId]); }, [driverId]);
useEffect(() => { useEffect(() => { loadDriver(); }, [loadDriver]);
loadDriver();
}, [loadDriver]);
// Close on Escape key
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h); window.addEventListener("keydown", h);
@@ -253,8 +248,8 @@ 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 }}>
{/* Avatar + name */}
<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,
@@ -269,14 +264,8 @@ export function DriverDetailPanel({
justifyContent: "center", justifyContent: "center",
}} }}
> >
{driver?.photoUrl && !avatarError ? ( {driver?.photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element <AvatarImg src={driver.photoUrl} name={driver.name} />
<img
src={driver.photoUrl}
alt={driver.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setAvatarError(true)}
/>
) : ( ) : (
<span style={{ fontSize: 20, fontWeight: 700, color: "var(--color-brand-600)" }}> <span style={{ fontSize: 20, fontWeight: 700, color: "var(--color-brand-600)" }}>
{driver?.name?.charAt(0) ?? "?"} {driver?.name?.charAt(0) ?? "?"}
@@ -301,7 +290,6 @@ export function DriverDetailPanel({
</div> </div>
</div> </div>
{/* Close button */}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -328,7 +316,6 @@ export function DriverDetailPanel({
{/* ── Scrollable content ── */} {/* ── 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 && ( {loading && (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}> <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
<Spinner size="sm" className="text-blue-600" /> <Spinner size="sm" className="text-blue-600" />
@@ -336,18 +323,15 @@ export function DriverDetailPanel({
</div> </div>
)} )}
{/* Error */}
{error && ( {error && (
<div <div style={{
style={{ borderRadius: "var(--radius-md)",
borderRadius: "var(--radius-md)", background: "#FEF2F2",
background: "#FEF2F2", border: "1px solid #FECACA",
border: "1px solid #FECACA", padding: "0.75rem 1rem",
padding: "0.75rem 1rem", fontSize: 13,
fontSize: 13, color: "#DC2626",
color: "#DC2626", }}>
}}
>
{error} {error}
</div> </div>
)} )}
@@ -357,51 +341,45 @@ export function DriverDetailPanel({
{/* Status badges */} {/* 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 <span style={{
style={{ borderRadius: "var(--radius-full)",
borderRadius: "var(--radius-full)", border: `1px solid ${statusConfig.border}`,
border: `1px solid ${statusConfig.border}`, background: statusConfig.bg,
background: statusConfig.bg, padding: "0.3rem 0.875rem",
padding: "0.3rem 0.875rem", fontSize: 12,
fontSize: 12, fontWeight: 700,
fontWeight: 700, color: statusConfig.color,
color: statusConfig.color, display: "inline-flex",
display: "inline-flex", alignItems: "center",
alignItems: "center", gap: 6,
gap: 6, }}>
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: statusConfig.dot }} /> <span style={{ width: 7, height: 7, borderRadius: "50%", background: statusConfig.dot }} />
{statusConfig.label} {statusConfig.label}
</span> </span>
)} )}
{!driver.isActive && ( {!driver.isActive && (
<span <span style={{
style={{ borderRadius: "var(--radius-full)",
borderRadius: "var(--radius-full)", border: "1px solid #FECACA",
border: "1px solid #FECACA", background: "#FEF2F2",
background: "#FEF2F2", padding: "0.3rem 0.875rem",
padding: "0.3rem 0.875rem", fontSize: 12,
fontSize: 12, fontWeight: 700,
fontWeight: 700, color: "#DC2626",
color: "#DC2626", }}>
}}
>
محذوف محذوف
</span> </span>
)} )}
</div> </div>
{/* ── Section: Personal Info ── */}
<SectionHeading title="البيانات الشخصية" /> <SectionHeading title="البيانات الشخصية" />
<DetailRow label="الاسم الكامل" value={driver.name} /> <DetailRow label="الاسم الكامل" value={driver.name} />
<DetailRow label="رقم الجوال" value={driver.phone} mono /> <DetailRow label="رقم الجوال" value={driver.phone} mono />
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} /> <DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
<DetailRow label="العنوان" value={driver.address ?? "—"} /> <DetailRow label="العنوان" value={driver.address ?? "—"} />
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} /> <DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} /> <DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
{/* ── Section: ID & GOSI ── */}
<SectionHeading title="الهوية والتأمينات" /> <SectionHeading title="الهوية والتأمينات" />
<DetailRow <DetailRow
label="نوع الهوية" label="نوع الهوية"
@@ -415,7 +393,6 @@ export function DriverDetailPanel({
/> />
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono /> <DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
{/* ── Section: License ── */}
<SectionHeading title="بيانات رخصة القيادة" /> <SectionHeading title="بيانات رخصة القيادة" />
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono /> <DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono />
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} /> <DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
@@ -425,7 +402,6 @@ export function DriverDetailPanel({
warn={isExpiringSoon(driver.licenseExpiry)} warn={isExpiringSoon(driver.licenseExpiry)}
/> />
{/* ── Section: Driver Card ── */}
<SectionHeading title="بطاقة السائق" /> <SectionHeading title="بطاقة السائق" />
<DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono /> <DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono />
<DetailRow <DetailRow
@@ -439,21 +415,18 @@ export function DriverDetailPanel({
/> />
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} /> <DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
{/* ── Section: System Info ── */}
<SectionHeading title="معلومات النظام" /> <SectionHeading title="معلومات النظام" />
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono /> <DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono />
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} /> <DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} /> <DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
{/* ── Section: Photos ── */}
<SectionHeading title="الصور والمستندات" /> <SectionHeading title="الصور والمستندات" />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem", marginTop: "0.5rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem", marginTop: "0.5rem" }}>
<PhotoCard url={driver.photoUrl} label="صورة السائق" /> <PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" /> <PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة البطاقة" /> <PhotoCard url={driver.driverCardPhotoUrl} label="صورة البطاقة" />
</div> </div>
{/* ── Status History ── */}
{driver.statusHistory && driver.statusHistory.length > 0 && ( {driver.statusHistory && driver.statusHistory.length > 0 && (
<> <>
<SectionHeading title="سجل الحالات" /> <SectionHeading title="سجل الحالات" />
@@ -474,9 +447,7 @@ export function DriverDetailPanel({
}} }}
> >
<div> <div>
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}> <span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
{s.label}
</span>
{h.reason && ( {h.reason && (
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}> <span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
{h.reason} {h.reason}
@@ -493,7 +464,6 @@ export function DriverDetailPanel({
</> </>
)} )}
{/* ── Report Panel ── */}
<DriverReportPanel driverId={driver.id} /> <DriverReportPanel driverId={driver.id} />
</> </>
)} )}
@@ -511,44 +481,32 @@ export function DriverDetailPanel({
flexShrink: 0, flexShrink: 0,
}} }}
> >
{/* Delete */}
<button <button
type="button" type="button"
onClick={() => onDelete(driver)} onClick={() => onDelete(driver)}
style={{ style={{
height: 40, height: 40, padding: "0 1rem",
padding: "0 1rem",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: "1px solid #FECACA", border: "1px solid #FECACA",
background: "#FEF2F2", background: "#FEF2F2",
fontSize: 13, fontSize: 13, fontWeight: 700, color: "#DC2626",
fontWeight: 700, cursor: "pointer", fontFamily: "var(--font-sans)",
color: "#DC2626",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}} }}
> >
حذف حذف
</button> </button>
{/* Edit */}
<button <button
type="button" type="button"
onClick={() => onEdit(driver)} onClick={() => onEdit(driver)}
style={{ style={{
height: 40, height: 40, padding: "0 1rem",
padding: "0 1rem",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: "1px solid var(--color-brand-200)", border: "1px solid var(--color-brand-200)",
background: "var(--color-brand-50, #EFF6FF)", background: "var(--color-brand-50, #EFF6FF)",
fontSize: 13, fontSize: 13, fontWeight: 700, color: "var(--color-brand-600)",
fontWeight: 700, cursor: "pointer", fontFamily: "var(--font-sans)",
color: "var(--color-brand-600)", display: "flex", alignItems: "center", gap: 6,
cursor: "pointer",
fontFamily: "var(--font-sans)",
display: "flex",
alignItems: "center",
gap: 6,
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
@@ -558,21 +516,16 @@ export function DriverDetailPanel({
تعديل تعديل
</button> </button>
{/* Close */}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
style={{ style={{
flex: 1, flex: 1, height: 40,
height: 40,
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
fontSize: 13, fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-sans)",
color: "var(--color-text-secondary)",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}} }}
> >
إغلاق إغلاق
@@ -583,3 +536,30 @@ export function DriverDetailPanel({
</> </>
); );
} }
// ── AvatarImg ─────────────────────────────────────────────────────────────────
// Separate component so key={src} forces a full remount on photo change,
// avoiding stale onError state from a previously failed load.
function AvatarImg({ src, name }: { src: string; name: string }) {
const [error, setError] = useState(false);
if (error) {
return (
<span style={{ fontSize: 20, fontWeight: 700, color: "var(--color-brand-600)" }}>
{name.charAt(0)}
</span>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
key={src}
src={src}
alt={name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setError(true)}
/>
);
}

View File

@@ -3,10 +3,10 @@
import { useEffect, useRef, useState } from "react"; import { 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 "@/services/api"; import { get } from "@/src/services/api";
import { getStoredToken } from "@/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { createDriverSchema, updateDriverSchema } from "@/validations/driver.validator"; import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator";
import type { DriverSchemaErrors } from "@/validations/driver.validator"; import type { DriverSchemaErrors } from "@/src/validations/driver.validator";
import type { import type {
Driver, Driver,
CreateDriverPayload, CreateDriverPayload,
@@ -14,8 +14,8 @@ import type {
NationalIdType, NationalIdType,
DriverCardType, DriverCardType,
DriverStatus, DriverStatus,
} from "@/types/driver"; } from "@/src/types/driver";
import type { Branch } from "@/types/branch"; import type { Branch } from "@/src/types/branch";
// ── Shared input style ─────────────────────────────────────────────────────── // ── Shared input style ───────────────────────────────────────────────────────
@@ -217,6 +217,8 @@ export function DriverFormModal({
if (address) payload.address = address; if (address) payload.address = address;
if (nationality) payload.nationality = nationality; if (nationality) payload.nationality = nationality;
if (nationalIdType) payload.nationalIdType = nationalIdType; if (nationalIdType) payload.nationalIdType = nationalIdType;
if (nationalId) payload.nationalId = nationalId;
if (nationalIdExpiry) payload.nationalIdExpiry = toIsoDateTime(nationalIdExpiry);
if (gosiNumber) payload.gosiNumber = gosiNumber; if (gosiNumber) payload.gosiNumber = gosiNumber;
if (licenseNumber) payload.licenseNumber = licenseNumber; if (licenseNumber) payload.licenseNumber = licenseNumber;
if (licenseType) payload.licenseType = licenseType; if (licenseType) payload.licenseType = licenseType;
@@ -233,10 +235,22 @@ export function DriverFormModal({
setSaving(true); setSaving(true);
setApiError(""); setApiError("");
const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew);
setSaving(false); try {
if (ok) onClose(); const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew);
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); if (ok) {
onClose();
} else {
// onSubmit returned false without throwing — show generic fallback
setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}
} catch (err) {
// onSubmit threw — surface the real API error message
const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
setApiError(message);
} finally {
setSaving(false);
}
}; };
// ── File input helper ────────────────────────────────────────────────────── // ── File input helper ──────────────────────────────────────────────────────
@@ -436,7 +450,7 @@ export function DriverFormModal({
{errors.nationalIdType && <span style={errorTextStyle}>{errors.nationalIdType}</span>} {errors.nationalIdType && <span style={errorTextStyle}>{errors.nationalIdType}</span>}
</label> </label>
{/* National ID number — not in backend validator, display only */} {/* National ID number */}
<label style={labelStyle}> <label style={labelStyle}>
رقم الهوية رقم الهوية
<span style={optionalLabelStyle}>(اختياري)</span> <span style={optionalLabelStyle}>(اختياري)</span>
@@ -449,7 +463,7 @@ export function DriverFormModal({
/> />
</label> </label>
{/* National ID expiry — not in backend validator, display only */} {/* National ID expiry */}
<label style={labelStyle}> <label style={labelStyle}>
انتهاء الهوية انتهاء الهوية
<span style={optionalLabelStyle}>(اختياري)</span> <span style={optionalLabelStyle}>(اختياري)</span>

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
export function PhotoCard({
url,
label,
}: {
url?: string | null;
label: string;
}) {
const [imgError, setImgError] = useState(false);
// Reset error state when url changes so updated images get a fresh load attempt
useEffect(() => {
setImgError(false);
}, [url]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
}}
>
{label}
</span>
<div
style={{
width: "100%",
aspectRatio: "4/3",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{url && !imgError ? (
// key={url} forces a full remount whenever the URL changes,
// clearing any stale error state from a previous failed load.
// eslint-disable-next-line @next/next/no-img-element
<img
key={url}
src={url}
alt={label}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setImgError(true)}
/>
) : (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)}
</div>
{url && !imgError && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 11,
color: "#2563EB",
textDecoration: "underline",
textAlign: "center",
}}
>
عرض الصورة
</a>
)}
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { DriverFormModal } from "./DriverFormModal";
export { DriverDeleteModal } from "./DriverDeleteModal";
export { DriverDetailPanel } from "./DriverDetailPanel";
export { PhotoCard } from "./DriverPhotos";

View File

@@ -2,8 +2,8 @@
import { useState } from "react"; import { useState } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import { driverService } from "../../services/driver.service"; import { driverService } from "@/src/services/driver.service";
import { getStoredToken } from "../../lib/auth"; import { getStoredToken } from "@/src/lib/auth";
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import React, { type FC } from "react"; import React, { type FC } from "react";
import { useClientOrders } from "@/hooks/useClientOrders"; import { useClientOrders } from "@/src/hooks/useClientOrders";
import { fmtDate, fmtAmount } from "@/lib/formatters"; import { fmtDate, fmtAmount } from "@/src/lib/formatters";
import { statusColor, statusLabel } from "@/lib/order-status"; import { statusColor, statusLabel } from "@/src/lib/order-status";
import { clearSession, type ClientSession } from "@/lib/session"; import { clearSession, type ClientSession } from "@/src/lib/session";
import type { OrderStatus } from "@/types/client"; import type { OrderStatus } from "@/src/types/order";
import { Spinner } from "../../Components/UI"; import { Spinner } from "../UI";
// ─── Order status tracker ───────────────────── // ─── Order status tracker ─────────────────────
const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"]; const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"];

View File

@@ -2,7 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { Trip } from "../../types/trip"; import type { Trip } from "@/src/types/trip";
interface TripDeleteModalProps { interface TripDeleteModalProps {
trip: Trip; trip: Trip;

View File

@@ -0,0 +1,732 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as yup from "yup";
import { Spinner } from "../UI";
import { get } from "@/src/services/api";
import { getStoredToken } from "@/src/lib/auth";
import {
createTripSchema,
updateTripSchema,
} from "@/src/validations/trip.validator";
import type { TripSchemaErrors } from "@/src/validations/trip.validator";
import type {
Trip,
TripStatus,
CreateTripPayload,
UpdateTripPayload,
} from "@/src/types/trip";
// ── Shared styles ────────────────────────────────────────────────────────────
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,
};
const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: "0.5rem 0 0",
};
const optionalLabelStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 500,
color: "var(--color-text-hint)",
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 ────────────────────────────────────────────────────────────────────
interface TripFormModalProps {
editTrip: Trip | null;
onClose: () => void;
onSubmit: (
payload: CreateTripPayload | UpdateTripPayload,
isNew: boolean,
) => Promise<boolean>;
}
// ── Component ────────────────────────────────────────────────────────────────
export function TripFormModal({
editTrip,
onClose,
onSubmit,
}: TripFormModalProps) {
const isNew = editTrip === null;
// ── Dropdown options ──────────────────────────────────────────────────────
const [drivers, setDrivers] = useState<DriverOption[]>([]);
const [cars, setCars] = useState<CarOption[]>([]);
const [branches, setBranches] = useState<BranchOption[]>([]);
useEffect(() => {
const token = getStoredToken();
get<{ data: { data: DriverOption[] } }>(
"driver?limit=100&status=Active",
token,
)
.then((res) =>
setDrivers(
(res as unknown as { data: { data: DriverOption[] } }).data?.data ??
[],
),
)
.catch(() => {});
get<{ data: { data: CarOption[] } }>("cars?limit=100&status=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(() => {});
}, []);
// ── Form state ────────────────────────────────────────────────────────────
const [title, setTitle] = useState(editTrip?.title ?? "");
const [driverId, setDriverId] = useState(editTrip?.driverId ?? "");
const [carId, setCarId] = useState(editTrip?.carId ?? "");
const [branchId, setBranchId] = useState(editTrip?.branchId ?? "");
const [status, setStatus] = useState<TripStatus>(
editTrip?.status ?? "Scheduled",
);
const [startTime, setStartTime] = useState(
editTrip?.startTime?.slice(0, 16) ?? "",
);
const [endTime, setEndTime] = useState(editTrip?.endTime?.slice(0, 16) ?? "");
const [collectedCount, setCollectedCount] = useState<string>(
editTrip?.collectedCount != null ? String(editTrip.collectedCount) : "",
);
const [deliveredCount, setDeliveredCount] = useState<string>(
editTrip?.deliveredCount != null ? String(editTrip.deliveredCount) : "",
);
const [returnedCount, setReturnedCount] = useState<string>(
editTrip?.returnedCount != null ? String(editTrip.returnedCount) : "",
);
const [totalCashCollected, setTotalCashCollected] = useState<string>(
editTrip?.totalCashCollected != null
? String(editTrip.totalCashCollected)
: "",
);
const [notes, setNotes] = useState(editTrip?.notes ?? "");
const [endReason, setEndReason] = useState(editTrip?.endReason ?? "");
const [reason, setReason] = useState(""); // update-only
const [errors, setErrors] = useState<TripSchemaErrors>({});
const [saving, setSaving] = useState(false);
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]);
// ── Error style helper ────────────────────────────────────────────────────
const inputStyle = (field: keyof TripSchemaErrors): React.CSSProperties => ({
...inputBase,
...(errors[field]
? { borderColor: "var(--color-danger)", background: "#FEF2F2" }
: {}),
});
const clearErr = (field: keyof TripSchemaErrors) =>
setErrors((p) => ({ ...p, [field]: undefined }));
// ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const raw: Record<string, unknown> = {
title: title || undefined,
driverId: driverId || undefined,
carId: carId || undefined,
branchId: branchId || undefined,
status: status || undefined,
startTime: startTime ? `${startTime}:00.000Z` : undefined,
endTime: endTime ? `${endTime}:00.000Z` : undefined,
collectedCount:
collectedCount !== "" ? Number(collectedCount) : undefined,
deliveredCount:
deliveredCount !== "" ? Number(deliveredCount) : undefined,
returnedCount: returnedCount !== "" ? Number(returnedCount) : undefined,
totalCashCollected:
totalCashCollected !== "" ? Number(totalCashCollected) : undefined,
notes: notes || undefined,
endReason: endReason || undefined,
...(!isNew && reason ? { reason } : {}),
};
// ── Client-side validation ────────────────────────────────────────────
try {
const schema = isNew ? createTripSchema : updateTripSchema;
await schema.validate(raw, { abortEarly: false });
} catch (err) {
if (err instanceof yup.ValidationError) {
const map: TripSchemaErrors = {};
err.inner.forEach((e) => {
if (e.path) (map as Record<string, string>)[e.path] = e.message;
});
setErrors(map);
}
return;
}
// ── Call parent handler (notification emitted by the hook) ────────────
setSaving(true);
try {
const ok = await onSubmit(
raw as CreateTripPayload | UpdateTripPayload,
isNew,
);
if (ok) onClose();
// on failure the hook already fired an error notification — nothing extra needed here
} finally {
setSaving(false);
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="trip-form-title"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 60,
background: "rgba(15,23,42,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
padding: "2rem 1rem",
overflowY: "auto",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 700,
background: "var(--color-surface)",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 56px rgba(0,0,0,.2)",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<h2
id="trip-form-title"
style={{
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
</h2>
<button
type="button"
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--color-text-muted)",
padding: 4,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
noValidate
style={{
padding: "1.5rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{/* ── Section: أساسيات الرحلة ── */}
<p style={sectionHeadingStyle}>أساسيات الرحلة</p>
{/* Title */}
<label style={labelStyle}>
عنوان الرحلة <span style={{ color: "var(--color-danger)" }}>*</span>
<input
ref={firstRef}
style={inputStyle("title")}
value={title}
onChange={(e) => {
setTitle(e.target.value);
clearErr("title");
}}
placeholder="مثال: توزيع الرياض الشمالي"
dir="rtl"
/>
{errors.title && <span style={errorTextStyle}>{errors.title}</span>}
</label>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
{/* Driver */}
<label style={labelStyle}>
السائق <span style={{ color: "var(--color-danger)" }}>*</span>
<select
style={{ ...inputStyle("driverId"), cursor: "pointer" }}
value={driverId}
onChange={(e) => {
setDriverId(e.target.value);
clearErr("driverId");
}}
dir="rtl"
>
<option value="">اختر السائق</option>
{drivers.map((d) => (
<option key={d.id} value={d.id}>
{d.name} {d.phone}
</option>
))}
</select>
{errors.driverId && (
<span style={errorTextStyle}>{errors.driverId}</span>
)}
</label>
{/* Car */}
<label style={labelStyle}>
السيارة <span style={{ color: "var(--color-danger)" }}>*</span>
<select
style={{ ...inputStyle("carId"), cursor: "pointer" }}
value={carId}
onChange={(e) => {
setCarId(e.target.value);
clearErr("carId");
}}
dir="rtl"
>
<option value="">اختر السيارة</option>
{cars.map((c) => (
<option key={c.id} value={c.id}>
{c.manufacturer} {c.model} {c.plateNumber}
</option>
))}
</select>
{errors.carId && (
<span style={errorTextStyle}>{errors.carId}</span>
)}
</label>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
{/* Branch */}
<label style={labelStyle}>
الفرع <span style={{ color: "var(--color-danger)" }}>*</span>
<select
style={{ ...inputStyle("branchId"), cursor: "pointer" }}
value={branchId}
onChange={(e) => {
setBranchId(e.target.value);
clearErr("branchId");
}}
dir="rtl"
>
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
{errors.branchId && (
<span style={errorTextStyle}>{errors.branchId}</span>
)}
</label>
{/* Status */}
<label style={labelStyle}>
الحالة
<select
style={{ ...inputBase, cursor: "pointer" }}
value={status}
onChange={(e) => setStatus(e.target.value as TripStatus)}
dir="rtl"
>
<option value="Scheduled">مجدولة</option>
<option value="InProgress">جارية</option>
<option value="Completed">مكتملة</option>
<option value="Cancelled">ملغاة</option>
</select>
</label>
</div>
{/* ── Section: التوقيت ── */}
<p style={sectionHeadingStyle}>التوقيت</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
وقت البدء <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="datetime-local"
style={inputStyle("startTime")}
value={startTime}
onChange={(e) => {
setStartTime(e.target.value);
clearErr("startTime");
}}
/>
{errors.startTime && (
<span style={errorTextStyle}>{errors.startTime}</span>
)}
</label>
<label style={labelStyle}>
وقت الانتهاء <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="datetime-local"
style={inputStyle("endTime")}
value={endTime}
onChange={(e) => {
setEndTime(e.target.value);
clearErr("endTime");
}}
/>
{errors.endTime && (
<span style={errorTextStyle}>{errors.endTime}</span>
)}
</label>
</div>
{/* ── Section: الأعداد والمبالغ ── */}
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
المجمّع <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
min="0"
style={inputStyle("collectedCount")}
value={collectedCount}
onChange={(e) => {
setCollectedCount(e.target.value);
clearErr("collectedCount");
}}
placeholder="0"
dir="ltr"
/>
{errors.collectedCount && (
<span style={errorTextStyle}>{errors.collectedCount}</span>
)}
</label>
<label style={labelStyle}>
المُسلَّم <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
min="0"
style={inputStyle("deliveredCount")}
value={deliveredCount}
onChange={(e) => {
setDeliveredCount(e.target.value);
clearErr("deliveredCount");
}}
placeholder="0"
dir="ltr"
/>
{errors.deliveredCount && (
<span style={errorTextStyle}>{errors.deliveredCount}</span>
)}
</label>
<label style={labelStyle}>
المُرتجع <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
min="0"
style={inputStyle("returnedCount")}
value={returnedCount}
onChange={(e) => {
setReturnedCount(e.target.value);
clearErr("returnedCount");
}}
placeholder="0"
dir="ltr"
/>
{errors.returnedCount && (
<span style={errorTextStyle}>{errors.returnedCount}</span>
)}
</label>
<label style={labelStyle}>
النقد المحصّل <span style={optionalLabelStyle}>(اختياري)</span>
<input
type="number"
min="0"
step="0.01"
style={inputStyle("totalCashCollected")}
value={totalCashCollected}
onChange={(e) => {
setTotalCashCollected(e.target.value);
clearErr("totalCashCollected");
}}
placeholder="0.00"
dir="ltr"
/>
{errors.totalCashCollected && (
<span style={errorTextStyle}>{errors.totalCashCollected}</span>
)}
</label>
</div>
{/* ── Section: ملاحظات ── */}
<p style={sectionHeadingStyle}>ملاحظات</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<label style={labelStyle}>
ملاحظات <span style={optionalLabelStyle}>(اختياري)</span>
<textarea
style={{
...inputBase,
height: 72,
padding: "0.5rem 0.75rem",
resize: "vertical",
}}
value={notes}
onChange={(e) => {
setNotes(e.target.value);
clearErr("notes");
}}
placeholder="أي ملاحظات إضافية…"
dir="rtl"
/>
{errors.notes && (
<span style={errorTextStyle}>{errors.notes}</span>
)}
</label>
<label style={labelStyle}>
سبب الإنهاء <span style={optionalLabelStyle}>(اختياري)</span>
<textarea
style={{
...inputBase,
height: 72,
padding: "0.5rem 0.75rem",
resize: "vertical",
}}
value={endReason}
onChange={(e) => {
setEndReason(e.target.value);
clearErr("endReason");
}}
placeholder="سبب إنهاء أو إلغاء الرحلة…"
dir="rtl"
/>
{errors.endReason && (
<span style={errorTextStyle}>{errors.endReason}</span>
)}
</label>
</div>
{/* reason — edit only (for reassigning driver/car) */}
{!isNew && (
<>
<p style={sectionHeadingStyle}>سجل التغيير</p>
<label style={labelStyle}>
سبب التعديل{" "}
<span style={optionalLabelStyle}>
(مطلوب عند تغيير السائق أو السيارة)
</span>
<input
style={inputBase}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
dir="rtl"
/>
</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>
);
}

View File

@@ -0,0 +1,2 @@
export { TripFormModal } from "./Tripformmodal";
export { TripDeleteModal } from "./Tripdeletemodal";

View File

@@ -0,0 +1,120 @@
"use client";
import { useRef, useState } from "react";
import { Spinner } from "../UI";
import { Toast } from "../UI/Toast";
import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth";
import type { TripNotification } from "@/src/hooks/useTrip";
interface TripReportPanelProps {
tripId: string;
}
// Extracts a readable message from any error shape the API might return.
function extractApiMessage(err: unknown, fallback: string): string {
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
const rd = (e["response"] as Record<string, unknown> | undefined)?.["data"];
if (rd && typeof rd === "object") {
const msg = (rd as Record<string, unknown>)["message"];
if (typeof msg === "string" && msg.trim()) return msg;
}
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"];
}
return fallback;
}
export function TripReportPanel({ tripId }: TripReportPanelProps) {
const [loading, setLoading] = useState(false);
const [notification, setNotification] = useState<TripNotification | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = (n: TripNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n);
timerRef.current = setTimeout(() => setNotification(null), 4000);
};
const handleGenerate = async () => {
setLoading(true);
try {
const token = getStoredToken();
const res = await tripService.getReport(tripId, token);
const url = (res as unknown as { data: { reportUrl: string } }).data?.reportUrl;
if (!url) {
notify({ type: "error", message: "لم يتم إرجاع رابط التقرير من الخادم." });
return;
}
window.open(url, "_blank", "noopener,noreferrer");
notify({ type: "success", message: "تم إنشاء التقرير بنجاح." });
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر إنشاء التقرير.") });
} finally {
setLoading(false);
}
};
return (
<>
<div
style={{
marginTop: "1.5rem",
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "1rem",
}}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 700,
margin: "0 0 0.75rem",
}}
>
إنشاء تقرير الرحلة
</p>
<button
type="button"
onClick={handleGenerate}
disabled={loading}
style={{
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "none",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
background: loading ? "var(--color-brand-400)" : "var(--color-brand-600)",
cursor: loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
whiteSpace: "nowrap",
}}
>
{loading && <Spinner size="sm" className="text-white" />}
{loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"}
</button>
</div>
{/* Toast scoped to this panel — sits at the bottom of the screen */}
<Toast
notification={notification}
onDismiss={() => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(null);
}}
/>
</>
);
}

View File

@@ -1,6 +1,6 @@
// Typed, accessible alert banner with optional dismiss button. // Typed, accessible alert banner with optional dismiss button.
import { cn } from "../../lib/utils"; import { cn } from "@/src/lib/utils";
export type AlertType = "error" | "info" | "success" | "warning"; export type AlertType = "error" | "info" | "success" | "warning";

View File

@@ -1,6 +1,6 @@
// Design-system button with variant, size, and loading state support. // Design-system button with variant, size, and loading state support.
import { cn } from "../../lib/utils"; import { cn } from "@/src/lib/utils";
import { Spinner } from "./Spinner"; import { Spinner } from "./Spinner";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {

View File

@@ -6,30 +6,36 @@ interface ConfirmDialogProps {
title: string; title: string;
description: string; description: string;
confirmLabel?: string; confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
loading?: boolean; loading?: boolean;
/** Subtitle shown in modal header */
subtitle?: string;
} }
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
title, title,
description, description,
confirmLabel = "Delete", confirmLabel = "تأكيد",
cancelLabel = "إلغاء",
onConfirm, onConfirm,
onCancel, onCancel,
loading = false, loading = false,
subtitle,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
return ( return (
<Modal <Modal
open={open} open={open}
title={title} title={title}
subtitle={subtitle}
onClose={onCancel} onClose={onCancel}
size="sm" size="sm"
footer={ footer={
<> <>
<Button variant="secondary" onClick={onCancel} disabled={loading}> <Button variant="secondary" onClick={onCancel} disabled={loading}>
Cancel {cancelLabel}
</Button> </Button>
<Button variant="danger" onClick={onConfirm} loading={loading}> <Button variant="danger" onClick={onConfirm} loading={loading}>
{confirmLabel} {confirmLabel}
@@ -37,7 +43,9 @@ export function ConfirmDialog({
</> </>
} }
> >
<p className="text-sm text-slate-600">{description}</p> <p className="text-[13px] text-[var(--color-text-muted)] leading-relaxed">
{description}
</p>
</Modal> </Modal>
); );
} }

View File

@@ -1,6 +1,6 @@
// Accessible labelled input with error and hint state. // Accessible labelled input with error and hint state.
import { cn } from "../../lib/utils"; import { cn } from "@/src/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
/** Visible label text — also used to derive the `htmlFor` id */ /** Visible label text — also used to derive the `htmlFor` id */

View File

@@ -0,0 +1,130 @@
import { useEffect } from "react";
import { cn } from "@/src/lib/utils";
interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
/** Footer slot typically action buttons */
footer?: React.ReactNode;
size?: "sm" | "md" | "lg";
/** Subtitle or badge shown below the title */
subtitle?: string;
}
const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = {
sm: "max-w-sm",
md: "max-w-lg",
lg: "max-w-2xl",
};
export function Modal({
open,
title,
onClose,
children,
footer,
size = "md",
subtitle,
}: ModalProps) {
// Escape key to close
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-[var(--color-slate-900)]/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className={cn(
"relative w-full flex flex-col max-h-[90vh]",
"bg-[var(--color-surface)]",
"rounded-[var(--radius-2xl)]",
"border border-[var(--color-border)]",
"shadow-[var(--shadow-overlay)]",
MODAL_SIZES[size],
)}
>
{/* Header */}
<div
className={cn(
"flex items-center justify-between",
"px-6 py-4",
"border-b border-[var(--color-border)]",
"bg-[var(--color-surface-muted)]",
"rounded-t-[var(--radius-2xl)]",
)}
>
<div>
{subtitle && (
<p className="text-[11px] font-semibold tracking-[0.3em] uppercase text-[var(--color-brand-600)] mb-1">
{subtitle}
</p>
)}
<h2
id="modal-title"
className="text-[17px] font-bold text-[var(--color-text-primary)]"
>
{title}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
className={cn(
"w-[34px] h-[34px] flex items-center justify-center",
"rounded-[var(--radius-md)]",
"border border-[var(--color-border)]",
"bg-[var(--color-surface)]",
"text-[18px] text-[var(--color-text-muted)]",
"hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-sunken)]",
"transition-colors cursor-pointer",
)}
>
×
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{children}
</div>
{/* Footer */}
{footer && (
<div
className={cn(
"flex justify-end gap-2",
"px-6 py-4",
"border-t border-[var(--color-border)]",
"bg-[var(--color-surface-muted)]",
"rounded-b-[var(--radius-2xl)]",
)}
>
{footer}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { cn } from "@/src/lib/utils";
interface PageHeaderProps {
title: string;
description?: string;
action?: React.ReactNode;
backHref?: string;
backLabel?: string;
className?: string;
}
export function PageHeader({
title,
description,
action,
backHref,
backLabel,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
"flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between",
"rounded-[var(--radius-xl)] border border-[var(--color-border)]",
"bg-[var(--color-surface)] shadow-[var(--shadow-card)]",
"px-8 py-6",
className,
)}
>
<div>
{backHref && (
<a
href={backHref}
className={cn(
"mb-2 inline-flex items-center gap-1",
"text-[12px] font-semibold text-[var(--color-brand-600)]",
"hover:text-[var(--color-brand-700)] transition-colors",
)}
>
{backLabel ?? "رجوع"}
</a>
)}
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]">
{title}
</h1>
{description && (
<p className="mt-1 text-[13px] text-[var(--color-text-muted)]">
{description}
</p>
)}
</div>
{action && (
<div className="mt-3 sm:mt-0 shrink-0">
{action}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import React, { forwardRef, SelectHTMLAttributes } from "react";
import { cn } from "@/src/lib/utils";
// ─── Select ──────────────────────────────────────────────────────────────────
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
wrapperClassName?: string;
children: React.ReactNode;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ label, error, wrapperClassName = "", className = "", id, children, ...rest },
ref
) {
const selectId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
return (
<div className={cn("flex flex-col gap-1", wrapperClassName)}>
{label && (
<label
htmlFor={selectId}
className="text-[12px] font-semibold text-[var(--color-text-muted)] uppercase tracking-wide"
>
{label}
</label>
)}
<select
ref={ref}
id={selectId}
className={cn(
"w-full h-10 rounded-[var(--radius-md)] border px-3 text-[13px]",
"bg-[var(--color-surface)] text-[var(--color-text-primary)]",
"transition-[border-color,box-shadow]",
"focus:outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15",
"disabled:bg-[var(--color-surface-muted)] disabled:text-[var(--color-text-hint)] disabled:cursor-not-allowed",
error
? "border-[var(--color-danger)] bg-[var(--color-danger-light)]"
: "border-[var(--color-border)]",
className,
)}
{...rest}
>
{children}
</select>
{error && (
<p className="text-[11px] font-medium text-[var(--color-danger)]">
{error}
</p>
)}
</div>
);
});

View File

@@ -1,4 +1,4 @@
import { cn } from "../../lib/utils"; import { cn } from "@/src/lib/utils";
export interface SpinnerProps { export interface SpinnerProps {
/** Visual size of the spinner */ /** Visual size of the spinner */

View File

@@ -0,0 +1,64 @@
import { forwardRef } from "react";
import { cn } from "@/src/lib/utils";
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
wrapperClassName?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ label, error, hint, wrapperClassName = "", className = "", id, ...rest },
ref
) {
const taId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
const errorId = taId ? `${taId}-error` : undefined;
const hintId = taId ? `${taId}-hint` : undefined;
return (
<div className={cn("flex flex-col gap-1", wrapperClassName)}>
{label && (
<label
htmlFor={taId}
className="text-[12px] font-semibold text-[var(--color-text-muted)] uppercase tracking-wide"
>
{label}
</label>
)}
<textarea
ref={ref}
id={taId}
rows={3}
aria-invalid={!!error}
aria-describedby={error ? errorId : hint ? hintId : undefined}
className={cn(
"w-full rounded-[var(--radius-md)] border px-3 py-2 text-[13px] resize-none",
"bg-[var(--color-surface)] text-[var(--color-text-primary)]",
"placeholder:text-[var(--color-text-hint)]",
"transition-[border-color,box-shadow]",
"focus:outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15",
"disabled:bg-[var(--color-surface-muted)] disabled:text-[var(--color-text-hint)]",
error
? "border-[var(--color-danger)] bg-[var(--color-danger-light)]"
: "border-[var(--color-border)]",
className,
)}
{...rest}
/>
{error && (
<p id={errorId} role="alert" className="text-[11px] font-medium text-[var(--color-danger)]">
{error}
</p>
)}
{hint && !error && (
<p id={hintId} className="text-[11px] text-[var(--color-text-hint)]">
{hint}
</p>
)}
</div>
);
});

101
src/Components/UI/Toast.tsx Normal file
View File

@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef, useState } from "react";
export interface ToastNotification {
type: "success" | "error";
message: string;
}
interface ToastProps {
notification: ToastNotification | null;
onDismiss?: () => void;
}
export function Toast({ notification, onDismiss }: ToastProps) {
// `visible` drives the mount/unmount; stays true for 300 ms after
// `notification` clears so the CSS exit transition has time to finish.
const [visible, setVisible] = useState(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (notification) {
if (exitTimer.current) clearTimeout(exitTimer.current);
setVisible(true);
} else {
exitTimer.current = setTimeout(() => setVisible(false), 300);
}
return () => { if (exitTimer.current) clearTimeout(exitTimer.current); };
}, [notification]);
if (!visible && !notification) return null;
const isSuccess = notification?.type === "success";
const isShowing = !!notification;
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: `translateX(-50%) translateY(${isShowing ? "0" : "16px"})`,
zIndex: 9999,
transition: "transform 250ms ease, opacity 250ms ease",
opacity: isShowing ? 1 : 0,
pointerEvents: isShowing ? "auto" : "none",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0.75rem 1rem 0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: isSuccess ? "#065F46" : "#7F1D1D",
color: "#FFFFFF",
fontSize: 13,
fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
maxWidth: "90vw",
fontFamily: "var(--font-sans)",
}}
>
<span style={{ fontSize: 16, flexShrink: 0 }}>
{isSuccess ? "✓" : "⚠"}
</span>
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{notification?.message}
</span>
{/* Dismiss button — only renders when a handler is provided */}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="إغلاق الإشعار"
style={{
marginLeft: 4,
flexShrink: 0,
background: "none",
border: "none",
color: "rgba(255,255,255,0.7)",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: "0 2px",
fontFamily: "var(--font-sans)",
}}
>
×
</button>
)}
</div>
</div>
);
}

View File

@@ -1,4 +1,3 @@
// components/ui/index.ts
export { Spinner, PageLoader, InlineLoader } from "./Spinner"; export { Spinner, PageLoader, InlineLoader } from "./Spinner";
export type { SpinnerProps } from "./Spinner"; export type { SpinnerProps } from "./Spinner";
@@ -14,3 +13,12 @@ export type { InputProps } from "./Input";
export { Textarea } from "./Textarea"; export { Textarea } from "./Textarea";
export { Select } from "./Select"; export { Select } from "./Select";
export { Toast } from "./Toast";
export type { ToastNotification } from "./Toast";
export { Modal } from "./ModalProps";
export { ConfirmDialog } from "./ConfirmDialog";
export { PageHeader } from "./PageHeader";
export { EmptyState } from "./EmptyState";
export { Badge } from "./Badge";

View File

@@ -2,7 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { User } from "../../types/user"; import type { User } from "@/src/types/user";
interface DeleteConfirmModalProps { interface DeleteConfirmModalProps {
user: User; user: User;

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Notification } from "../../hooks/useUser"; import type { Notification } from "@/src/hooks/useUser";
interface ToastProps { interface ToastProps {
notification: Notification | null; notification: Notification | null;

View File

@@ -2,9 +2,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import { getStoredToken } from "../../lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { userService } from "../../services/user.service"; import { userService } from "@/src/services/user.service";
import type { UserDetail } from "../../types/user"; import type { UserDetail } from "@/src/types/user";
interface UserDetailModalProps { interface UserDetailModalProps {
userId: string; userId: string;

View File

@@ -3,10 +3,10 @@
import { useEffect, useRef, useState } from "react"; import { 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 { createUserSchema, updateUserSchema } from "@/validations/user.validator"; import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
import type { Branch } from "../../types/branch"; import type { Branch } from "@/src/types/branch";
import type { Role } from "../../types/role"; import type { Role } from "@/src/types/role";
import type { FormErrors, User, UserFormData } from "../../types/user"; import type { FormErrors, User, UserFormData } from "@/src/types/user";
// ── fixed styles ───────────────────────────────────────── // ── fixed styles ─────────────────────────────────────────
const S = { const S = {

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { User } from "../../types/user"; import type { User } from "@/src/types/user";
// ── role badge ─────────────────────────────────────────────────────────────── // ── role badge ───────────────────────────────────────────────────────────────
function RoleBadge({ name }: { name?: string }) { function RoleBadge({ name }: { name?: string }) {

View File

@@ -0,0 +1,5 @@
export { UserTable } from "./UserTable";
export { UserFormModal } from "./UserFormModal";
export { UserDetailModal } from "./UserDetailModal";
export { DeleteConfirmModal } from "./DeleteConfirmModal";
export { Toast } from "./Toast";

View File

@@ -1,10 +1,9 @@
"use client"; "use client";
// Components/Car/CarDeleteModal.tsx
// Confirmation dialog before soft-deleting a car.
import { useEffect } from "react"; import { useEffect } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { Car } from "../../types/car"; import type { Car } from "@/src/types/car";
interface CarDeleteModalProps { interface CarDeleteModalProps {
car: Car; car: Car;

View File

@@ -1,13 +1,11 @@
"use client"; "use client";
// Components/Car/CarDetailPanel.tsx
// Slide-in panel showing full car details with action buttons.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import { CarImageGallery } from "./CarImageGallery"; import { CarImageGallery } from "./CarImageGallery";
import { useCarDetail } from "../../hooks/useCars"; import { useCarDetail } from "@/src/hooks/useCars";
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "../../types/car"; import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
import type { Car, InsuranceStatus } from "../../types/car"; import type { Car, InsuranceStatus } from "@/src/types/car";
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────

View File

@@ -1,20 +1,19 @@
"use client"; "use client";
// Components/Car/CarFormModal.tsx
import { useEffect, useRef, useState } from "react"; import { 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 "../../services/api"; import { get } from "@/src/services/api";
import { getStoredToken } from "../../lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { createCarSchema, updateCarSchema } from "@/validations/car.validator"; import { createCarSchema, updateCarSchema } from "@/src/validations/car.validator";
import type { import type {
Car, Car,
CarFormErrors, CarFormErrors,
CreateCarPayload, CreateCarPayload,
InsuranceStatus, InsuranceStatus,
UpdateCarPayload, UpdateCarPayload,
} from "../../types/car"; } from "@/src/types/car";
import type { Branch } from "../../types/branch"; import type { Branch } from "@/src/types/branch";
// ── Shared input style ──────────────────────────────────────────────────────── // ── Shared input style ────────────────────────────────────────────────────────

View File

@@ -4,9 +4,9 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import { useCarImages } from "../../hooks/useCars"; import { useCarImages } from "@/src/hooks/useCars";
import { STAGE_MAP } from "../../types/car"; import { STAGE_MAP } from "@/src/types/car";
import type { Car, CarImage, ImageStage } from "../../types/car"; import type { Car, CarImage, ImageStage } from "@/src/types/car";
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,4 @@
export { CarFormModal } from "./CarFormModal";
export { CarDetailPanel } from "./CarDetailPanel";
export { CarDeleteModal } from "./CarDeleteModal";
export { CarImageGallery } from "./CarImageGallery";

View File

@@ -0,0 +1,130 @@
"use client";
// Components/Role/DeleteRoleModal.tsx
// Confirm deletion of a role — mirrors DeleteConfirmModal from Users.
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Role } from "../../../services/role.service";
interface DeleteRoleModalProps {
role: Role;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onCancel]);
const permCount = role.permissions?.length ?? 0;
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: 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">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</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>
{permCount > 0 && (
<p style={{
marginTop: 8, fontSize: 12,
background: "#FEF3C7", color: "#92400E",
border: "1px solid #FDE68A",
borderRadius: "var(--radius-md)",
padding: "0.5rem 0.75rem",
}}>
يحتوي هذا الدور على {permCount} صلاحية مرتبطة، سيتم إلغاء ارتباطها تلقائياً.
</p>
)}
<p style={{ marginTop: 6, fontSize: 12, color: "#DC2626" }}>
لا يمكن التراجع عن هذا الإجراء.
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
type="button"
onClick={onCancel}
disabled={deleting}
style={{
flex: 1, height: 40,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: deleting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء
</button>
<button
type="button"
onClick={onConfirm}
disabled={deleting}
style={{
flex: 1, height: 40,
borderRadius: "var(--radius-md)",
border: "none",
background: "#DC2626",
fontSize: 13, fontWeight: 700,
color: "#FFF",
cursor: deleting ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
fontFamily: "var(--font-sans)",
opacity: deleting ? 0.7 : 1,
}}
>
{deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,375 @@
"use client";
import { useState } from "react";
import { Alert, Spinner } from "../UI";
import type { Role, Permission } from "../../../services/role.service";
const MODULE_LABELS: Record<string, string> = {
Role: "الأدوار",
User: "المستخدمون",
Branch: "الفروع",
Car: "المركبات",
CarImage: "صور المركبات",
Driver: "السائقون",
Order: "الطلبات",
Trip: "الرحلات",
Client: "العملاء",
Permission: "الصلاحيات",
Audit: "سجل التدقيق",
Dashboard: "لوحة التحكم",
Maintenance: "الصيانة",
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
function groupByModule(
permissions: Permission[],
): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
(acc[p.module || "أخرى"] ??= []).push(p);
return acc;
}, {});
}
interface RoleDetailModalProps {
role: Role;
allPermissions: Permission[];
onClose: () => void;
onSave: (roleId: string, permissionIds: string[]) => Promise<boolean>;
}
export function RoleDetailModal({
role,
allPermissions,
onClose,
onSave,
}: RoleDetailModalProps) {
const currentIds = role.permissions?.map((rp) => rp.permission.id) ?? [];
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const [saved, setSaved] = useState(false);
const grouped = groupByModule(allPermissions);
const activeSet = new Set(currentIds);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
setSaved(false);
};
const handleSave = async () => {
setSaving(true);
setApiError("");
const ok = await onSave(role.id, [...selected]);
setSaving(false);
if (ok) {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
setApiError("حدث خطأ أثناء حفظ الصلاحيات. حاول مرة أخرى.");
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="role-detail-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",
}}
>
<div
onClick={(e) => e.stopPropagation()}
dir="rtl"
style={{
width: "100%",
maxWidth: 640,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
maxHeight: "90vh",
}}
>
{/* 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)",
flexShrink: 0,
}}
>
<div>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
تفاصيل الدور
</p>
<h2
id="role-detail-title"
style={{
fontSize: 17,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: "4px 0 0",
}}
>
{role.name}
</h2>
{role.description && (
<p
style={{
fontSize: 12,
color: "var(--color-text-muted)",
marginTop: 4,
}}
>
{role.description}
</p>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
style={{
width: 34,
height: 34,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer",
fontSize: 18,
color: "var(--color-text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
×
</button>
</div>
{/* Body */}
<div
style={{
padding: "1.5rem",
overflowY: "auto",
flex: 1,
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{apiError && (
<Alert
type="error"
message={apiError}
onClose={() => setApiError("")}
/>
)}
{saved && (
<Alert
type="success"
message="تم حفظ التغييرات بنجاح."
onClose={() => setSaved(false)}
/>
)}
<p style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{selected.size} من {allPermissions.length} صلاحية مفعّلة لهذا الدور
</p>
<div
style={{
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
}}
>
{Object.entries(grouped).map(([mod, perms], idx) => (
<div
key={mod}
style={{
borderBottom:
idx < Object.keys(grouped).length - 1
? "1px solid var(--color-border)"
: "none",
}}
>
<div
style={{
padding: "0.625rem 1rem",
background: "var(--color-surface-muted)",
fontSize: 12,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
{moduleLabel(mod)}
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(180px, 1fr))",
gap: "0.5rem",
padding: "0.75rem 1rem",
}}
>
{perms.map((perm) => {
const checked = selected.has(perm.id);
const wasActive = activeSet.has(perm.id);
return (
<label
key={perm.id}
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
padding: "0.5rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked
? "#EFF6FF"
: "var(--color-surface-muted)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(perm.id)}
style={{
width: 14,
height: 14,
marginTop: 1,
cursor: "pointer",
accentColor: "#2563EB",
}}
/>
<div>
<p
style={{
fontSize: 11,
fontWeight: 600,
color: checked
? "#1D4ED8"
: "var(--color-text-primary)",
margin: 0,
}}
>
{perm.name}
</p>
<span
style={{
fontSize: 9,
fontWeight: 700,
padding: "1px 6px",
borderRadius: "var(--radius-full)",
color: wasActive ? "#166534" : "#991B1B",
background: wasActive ? "#DCFCE7" : "#FEF2F2",
}}
>
{wasActive ? "نشطة حالياً" : "غير نشطة"}
</span>
</div>
</label>
);
})}
</div>
</div>
))}
</div>
</div>
{/* Footer */}
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
flexShrink: 0,
}}
>
<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="button"
onClick={handleSave}
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 ? "جارٍ الحفظ…" : "حفظ التغييرات"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,510 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Alert, Spinner } from "../UI";
import type { Role, RoleFormData, Permission } from "../../../services/role.service";
// ── Styles ────────────────────────────────────────────────────────────────────
const S = {
input: {
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)",
} as React.CSSProperties,
label: {
display: "flex", flexDirection: "column" as const,
gap: 6, fontSize: 12, fontWeight: 600,
color: "var(--color-text-secondary)",
} as React.CSSProperties,
errorText: {
fontSize: 11, color: "var(--color-danger)", fontWeight: 500,
} as React.CSSProperties,
};
// ── Module label map (Arabic) ─────────────────────────────────────────────────
const MODULE_LABELS: Record<string, string> = {
Role: "الأدوار",
User: "المستخدمون",
Branch: "الفروع",
Car: "المركبات",
CarImage: "صور المركبات",
Driver: "السائقون",
Order: "الطلبات",
Trip: "الرحلات",
Client: "العملاء",
Permission: "الصلاحيات",
Audit: "سجل التدقيق",
Dashboard: "لوحة التحكم",
Maintenance: "الصيانة",
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Group permissions by their module field */
function groupByModule(permissions: Permission[]): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
const key = p.module || "أخرى";
if (!acc[key]) acc[key] = [];
acc[key].push(p);
return acc;
}, {});
}
interface FormErrors {
name?: string;
}
function validate(name: string): FormErrors {
const e: FormErrors = {};
if (!name.trim()) e.name = "اسم الدور مطلوب";
else if (name.trim().length < 2) e.name = "الاسم يجب أن يكون حرفين على الأقل";
return e;
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface RoleFormModalProps {
editRole: Role | null; // null = create mode
permissions: Permission[]; // all available permissions
onClose: () => void;
onSubmit: (data: RoleFormData, currentPermIds: string[]) => Promise<boolean>;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: RoleFormModalProps) {
const isNew = editRole === null;
// Current permission IDs attached to the role being edited
const currentPermIds = editRole?.permissions?.map(rp => rp.permission.id) ?? [];
const [name, setName] = useState(editRole?.name ?? "");
const [description, setDescription] = useState(editRole?.description ?? "");
const [selectedPerms, setSelectedPerms] = useState<Set<string>>(new Set(currentPermIds));
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const [searchPerm, setSearchPerm] = useState("");
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
const firstInputRef = useRef<HTMLInputElement>(null);
const grouped = groupByModule(permissions);
// Auto-expand modules that have selected permissions or match search
useEffect(() => {
if (editRole) {
const modulesWithSelected = new Set<string>();
permissions.forEach(p => {
if (currentPermIds.includes(p.id)) modulesWithSelected.add(p.module);
});
setExpandedModules(modulesWithSelected);
}
firstInputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Keyboard: Escape closes
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
// ── Checkbox helpers ───────────────────────────────────────────────────────
const togglePerm = (id: string) => {
setSelectedPerms(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleModule = (module: string) => {
const modulePerms = (grouped[module] ?? []).map(p => p.id);
const allSelected = modulePerms.every(id => selectedPerms.has(id));
setSelectedPerms(prev => {
const next = new Set(prev);
if (allSelected) modulePerms.forEach(id => next.delete(id));
else modulePerms.forEach(id => next.add(id));
return next;
});
};
const toggleExpandModule = (module: string) => {
setExpandedModules(prev => {
const next = new Set(prev);
if (next.has(module)) next.delete(module);
else next.add(module);
return next;
});
};
const selectAll = () => setSelectedPerms(new Set(permissions.map(p => p.id)));
const clearAll = () => setSelectedPerms(new Set());
// ── Submit ─────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errs = validate(name);
if (Object.keys(errs).length) { setErrors(errs); return; }
setSaving(true);
setApiError("");
const ok = await onSubmit(
{ name: name.trim(), description: description.trim(), permissionIds: [...selectedPerms] },
currentPermIds,
);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
};
// ── Filter permissions by search ───────────────────────────────────────────
const filteredGrouped = Object.entries(grouped).reduce<Record<string, Permission[]>>((acc, [mod, perms]) => {
if (!searchPerm.trim()) {
acc[mod] = perms;
} else {
const q = searchPerm.trim().toLowerCase();
const filtered = perms.filter(p =>
p.name.toLowerCase().includes(q) ||
p.slug.toLowerCase().includes(q) ||
p.module.toLowerCase().includes(q)
);
if (filtered.length) acc[mod] = filtered;
}
return acc;
}, {});
const totalSelected = selectedPerms.size;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="role-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",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 640,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
maxHeight: "90vh",
}}
>
{/* ── Modal 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)",
flexShrink: 0,
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
{isNew ? "إضافة دور" : "تعديل دور"}
</p>
<h2 id="role-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "دور جديد" : editRole.name}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)", cursor: "pointer",
fontSize: 18, color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── Scrollable body ── */}
<form
onSubmit={handleSubmit}
noValidate
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.25rem", overflowY: "auto", flex: 1 }}
>
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
{/* Name field */}
<label style={S.label}>
اسم الدور *
<input
ref={firstInputRef}
value={name}
onChange={e => { setName(e.target.value); if (errors.name) setErrors({}); }}
placeholder="مدير النظام"
autoComplete="off"
dir="rtl"
style={{
...S.input,
...(errors.name ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
}}
/>
{errors.name && <span style={S.errorText}>{errors.name}</span>}
</label>
{/* Description field */}
<label style={S.label}>
الوصف (اختياري)
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="وصف مختصر لمهام هذا الدور…"
rows={2}
dir="rtl"
style={{
...S.input,
height: "auto",
padding: "0.5rem 0.75rem",
resize: "vertical",
minHeight: 60,
}}
/>
</label>
{/* ── Permissions section ── */}
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<div>
<p style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-secondary)", margin: 0 }}>
الصلاحيات
</p>
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 2 }}>
{totalSelected} من {permissions.length} صلاحية محددة
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={selectAll}
style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "var(--color-brand-50)", border: "1px solid var(--color-brand-200)", borderRadius: "var(--radius-md)", padding: "4px 10px", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
تحديد الكل
</button>
<button type="button" onClick={clearAll}
style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", background: "var(--color-surface-muted)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)", padding: "4px 10px", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء الكل
</button>
</div>
</div>
{/* Permission search */}
<div style={{ position: "relative", marginBottom: "0.75rem" }}>
<svg style={{ position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)", width: 14, height: 14, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input
type="text"
placeholder="بحث في الصلاحيات…"
value={searchPerm}
onChange={e => setSearchPerm(e.target.value)}
dir="rtl"
style={{
...S.input,
paddingRight: 30, height: 36, fontSize: 12,
}}
/>
</div>
{/* Grouped permission modules */}
{permissions.length === 0 ? (
<div style={{ textAlign: "center", padding: "2rem", fontSize: 13, color: "var(--color-text-muted)" }}>
جارٍ تحميل الصلاحيات
</div>
) : Object.keys(filteredGrouped).length === 0 ? (
<div style={{ textAlign: "center", padding: "1.5rem", fontSize: 12, color: "var(--color-text-muted)" }}>
لا توجد نتائج لـ &quot;{searchPerm}&quot;
</div>
) : (
<div style={{
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
}}>
{Object.entries(filteredGrouped).map(([mod, perms], idx) => {
const allChecked = perms.every(p => selectedPerms.has(p.id));
const someChecked = !allChecked && perms.some(p => selectedPerms.has(p.id));
const isExpanded = expandedModules.has(mod) || !!searchPerm;
const checkedCount = perms.filter(p => selectedPerms.has(p.id)).length;
return (
<div key={mod} style={{ borderBottom: idx < Object.keys(filteredGrouped).length - 1 ? "1px solid var(--color-border)" : "none" }}>
{/* Module header row */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.625rem 1rem",
background: isExpanded ? "var(--color-surface-muted)" : "var(--color-surface)",
cursor: "pointer",
transition: "background 150ms",
}}>
{/* Module checkbox (select/deselect all in module) */}
<input
type="checkbox"
checked={allChecked}
ref={el => { if (el) el.indeterminate = someChecked; }}
onChange={() => toggleModule(mod)}
onClick={e => e.stopPropagation()}
style={{ width: 15, height: 15, cursor: "pointer", flexShrink: 0, accentColor: "#2563EB" }}
/>
{/* Module name */}
<button
type="button"
onClick={() => toggleExpandModule(mod)}
style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "space-between",
background: "none", border: "none", cursor: "pointer", padding: 0,
fontFamily: "var(--font-sans)",
}}
>
<span style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-primary)" }}>
{moduleLabel(mod)}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{checkedCount > 0 && (
<span style={{
fontSize: 10, fontWeight: 700,
color: "#2563EB",
background: "var(--color-brand-50)",
border: "1px solid var(--color-brand-200)",
borderRadius: "var(--radius-full)",
padding: "1px 7px",
}}>
{checkedCount}/{perms.length}
</span>
)}
<svg
style={{
width: 14, height: 14,
color: "var(--color-text-muted)",
transform: isExpanded ? "rotate(180deg)" : "none",
transition: "transform 200ms",
}}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
>
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
</button>
</div>
{/* Permissions grid */}
{isExpanded && (
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
gap: "0.5rem",
padding: "0.75rem 1rem 0.875rem",
background: "var(--color-surface)",
}}>
{perms.map(perm => {
const checked = selectedPerms.has(perm.id);
return (
<label
key={perm.id}
style={{
display: "flex", alignItems: "flex-start", gap: 8,
padding: "0.5rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked ? "#EFF6FF" : "var(--color-surface-muted)",
cursor: "pointer",
transition: "all 150ms",
userSelect: "none",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => togglePerm(perm.id)}
style={{ width: 14, height: 14, marginTop: 1, cursor: "pointer", flexShrink: 0, accentColor: "#2563EB" }}
/>
<div>
<p style={{ fontSize: 11, fontWeight: 600, color: checked ? "#1D4ED8" : "var(--color-text-primary)", margin: 0, lineHeight: 1.4 }}>
{perm.name}
</p>
<p style={{ fontSize: 10, color: "var(--color-text-muted)", margin: "2px 0 0", fontFamily: "var(--font-mono)" }}>
{perm.slug}
</p>
</div>
</label>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* ── Action buttons ── */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.25rem", flexShrink: 0 }}>
<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>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import { Spinner } from "../UI";
import type { Role } from "../../../services/role.service";
// ── Styles ──────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Sub-components ─────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function IconBtn({
onClick, title, color, bg, borderColor, children,
}: {
onClick: () => void;
title: string;
color: string;
bg: string;
borderColor: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={e => { e.stopPropagation(); onClick(); }}
style={{
width: 32, height: 32,
borderRadius: "var(--radius-md)",
border: `1px solid ${borderColor}`,
background: bg, color,
cursor: "pointer",
display: "inline-flex", alignItems: "center", justifyContent: "center",
transition: "opacity 150ms",
}}
>
{children}
</button>
);
}
// ── Props ──────────────────────────────────────────────────────────────────────
interface RoleTableProps {
roles: Role[];
loading: boolean;
search: string;
page: number;
pages: number;
onEdit: (role: Role) => void;
onDelete: (role: Role) => void;
onView: (role: Role) => void;
onAddFirst: () => void;
onPageChange: (p: number) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function RoleTable({
roles, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange,
}: RoleTableProps) {
return (
<div style={cardStyle}>
{/* Column headers */}
<div
dir="rtl"
style={{
display: "grid",
gridTemplateColumns: "2fr 2.5fr 1fr 1fr 80px",
...thStyle,
}}
>
<span>الدور</span>
<span>الصلاحيات</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* Loading */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : roles.length === 0 ? (
/* Empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها."}
</p>
{!search && (
<button
type="button"
onClick={onAddFirst}
style={{
marginTop: 12, fontSize: 13, fontWeight: 600,
color: "var(--color-brand-600)",
background: "none", border: "none",
cursor: "pointer", textDecoration: "underline",
}}
>
أضف أول دور
</button>
)}
</div>
) : (
/* Data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{roles.map((role, i) => {
const permCount = role.permissions?.length ?? 0;
// Show up to 3 permission module badges
const modules = [...new Set(role.permissions?.map(rp => rp.permission.module) ?? [])].slice(0, 3);
const extra = (role.permissions?.length ?? 0) - modules.length;
return (
<li
key={role.id}
onClick={() => onView(role)}
style={{
display: "grid",
gridTemplateColumns: "2fr 2.5fr 1fr 1fr 80px",
alignItems: "center",
gap: "0.5rem",
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
cursor: "pointer",
fontSize: 13,
}}
>
{/* Role name + ID */}
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
{role.name}
</p>
{role.id && (
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>
{role.id.slice(0, 8)}
</p>
)}
</div>
{/* Permissions preview */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.25rem", alignItems: "center" }}>
{permCount === 0 ? (
<span style={{ fontSize: 11, color: "var(--color-text-hint)" }}>لا توجد صلاحيات</span>
) : (
<>
{modules.map(mod => (
<span key={mod} style={{
fontSize: 10, fontWeight: 600,
color: "#374151",
background: "#F3F4F6",
border: "1px solid #E5E7EB",
borderRadius: "var(--radius-full)",
padding: "2px 7px",
}}>
{mod}
</span>
))}
{extra > 0 && (
<span style={{
fontSize: 10, fontWeight: 700,
color: "#2563EB",
background: "var(--color-brand-50)",
border: "1px solid var(--color-brand-200)",
borderRadius: "var(--radius-full)",
padding: "2px 7px",
}}>
+{extra}
</span>
)}
<span style={{ fontSize: 10, color: "var(--color-text-muted)", marginRight: 2 }}>
({permCount} صلاحية)
</span>
</>
)}
</div>
{/* Status badge */}
<StatusBadge active={role.isActive !== false} />
{/* Created date */}
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(role.createdAt).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
})}
</span>
{/* Actions */}
<div style={{ display: "flex", justifyContent: "center", gap: 6 }}>
<IconBtn
title={`تعديل ${role.name}`}
color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE"
onClick={() => onEdit(role)}
>
<svg width="14" height="14" 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>
</IconBtn>
<IconBtn
title={`حذف ${role.name}`}
color="#DC2626" bg="#FEF2F2" borderColor="#FECACA"
onClick={() => onDelete(role)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</IconBtn>
</div>
</li>
);
})}
</ul>
)}
{/* Pagination */}
{pages > 1 && (
<div
dir="rtl"
style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
borderTop: "1px solid var(--color-border)",
padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button
key={btn.label}
type="button"
onClick={btn.action}
disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12,
color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}
>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import type { RoleNotification } from "../../../hooks/useRole";
interface RoleToastProps {
notification: RoleNotification | null;
}
export function RoleToast({ notification }: RoleToastProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (notification) {
setVisible(true);
} else {
const t = setTimeout(() => setVisible(false), 300);
return () => clearTimeout(t);
}
}, [notification]);
if (!visible && !notification) return null;
const isSuccess = notification?.type === "success";
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "fixed", bottom: 24, left: "50%",
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
zIndex: 9999,
transition: "transform 250ms ease, opacity 250ms ease",
opacity: notification ? 1 : 0,
pointerEvents: "none",
}}
>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: isSuccess ? "#065F46" : "#7F1D1D",
color: "#FFFFFF",
fontSize: 13, fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
maxWidth: "90vw", whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}>
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
<span>{notification?.message}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { RoleFormModal } from "./RoleFormModal";
export { RoleTable } from "./RoleTable";
export { RoleDetailModal } from "./RoleDetailModal";
export { DeleteRoleModal } from "./DeleteRoleModal";
export { RoleToast } from "./RoleToast";

View File

@@ -1,11 +1,10 @@
"use client"; "use client";
// hooks/useCars.ts
// All custom hooks for the Car module.
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { getStoredToken } from "../lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { carService } from "../services/car.service"; import { carService } from "@/src/services/car.service";
import { get } from "../services/api"; import { get } from "@/src/services/api";
import type { import type {
Car, Car,
CarImage, CarImage,

View File

@@ -2,14 +2,14 @@
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "../lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { clientAddressService } from "../services/clientAddress.service"; import { clientAddressService } from "@/src/services/clientAddress.service";
import type { import type {
ClientAddress, ClientAddress,
ClientAddressFormData, ClientAddressFormData,
AddressTableAction, AddressTableAction,
AddressTableState, AddressTableState,
} from "../types/client"; } from "@/src/types/client";
// ── Notification ─────────────────────────────────────────────────────────── // ── Notification ───────────────────────────────────────────────────────────
export interface Notification { export interface Notification {

View File

@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { clientService } from "@/src/services/client.service";
import type { Order } from "@/src/types/order";
interface State {
orders: Order[];
error: string | null;
loading: boolean;
}
/**
* Fetches all orders for a client session.
*
* @example
* const { orders, loading, error } = useClientOrders(session.id);
*/
export function useClientOrders(clientId: string): State {
const [state, setState] = useState<State>({
orders: [], error: null, loading: true,
});
useEffect(() => {
if (!clientId) {
setState({ orders: [], error: null, loading: false });
return;
}
let cancelled = false;
clientService
.getOrders(clientId)
.then(orders => {
if (!cancelled) setState({ orders, error: null, loading: false });
})
.catch((err: Error) => {
if (!cancelled) setState({ orders: [], error: err.message, loading: false });
});
return () => { cancelled = true; };
}, [clientId]);
return state;
}

View File

@@ -1,14 +1,14 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "../lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { clientService } from "../services/client.service"; import { clientService } from "@/src/services/client.service";
import type { import type {
Client, Client,
ClientFormData, ClientFormData,
ClientTableAction, ClientTableAction,
ClientTableState, ClientTableState,
} from "../types/client"; } from "@/src/types/client";
// ── Notification (mirrors useUsers Notification) ─────────────────────────── // ── Notification (mirrors useUsers Notification) ───────────────────────────
export interface Notification { export interface Notification {

View File

@@ -2,9 +2,9 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { dashboardService } from "@/services/dashboard.service"; import { dashboardService } from "@/src/services/dashboard.service";
import { getStoredToken } from "@/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import type { DashboardSummary } from "@/types/dashboard"; import type { DashboardSummary } from "@/src/types/dashboard";
interface State { interface State {
data: DashboardSummary | null; data: DashboardSummary | null;

211
src/hooks/useDriver.ts Normal file
View File

@@ -0,0 +1,211 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { driverService } from "@/src/services/driver.service";
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
import type { ToastNotification } from "@/src/Components/UI";
export type DriverNotification = ToastNotification;
// ── Table state / reducer ──────────────────────────────────────────────────
interface TableState {
drivers: Driver[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type TableAction =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; drivers: Driver[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "DELETE"; id: string }
| { type: "UPDATE"; driver: Driver }
| { 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, drivers: a.drivers, total: a.total, pages: a.pages };
case "LOAD_ERR":
return { ...s, loading: false, error: a.error };
case "DELETE":
return { ...s, drivers: s.drivers.filter((d) => d.id !== a.id) };
// Instantly reflect updated driver in the list without a full reload
case "UPDATE":
return { ...s, drivers: s.drivers.map((d) => (d.id === a.driver.id ? a.driver : d)) };
case "CLEAR_ERR":
return { ...s, error: null };
default:
return s;
}
}
const initialState: TableState = {
drivers: [],
loading: true,
total: 0,
pages: 1,
error: null,
};
// ── Main hook ──────────────────────────────────────────────────────────────
export function useDrivers() {
const [state, dispatch] = useReducer(reducer, initialState);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [notification, setNotification] = useState<ToastNotification | null>(null);
// Show a toast for 4 seconds then auto-dismiss
const notify = useCallback((n: ToastNotification) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
}, []);
// ── Fetch drivers ─────────────────────────────────────────────────────────
const loadDrivers = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const res = await driverService.getAll(p, q, token);
const payload = (
res as unknown as {
data: {
data: Driver[];
pagination?: { total: number; pages: number };
meta?: { total: number; pages: number };
};
}
).data ?? res;
dispatch({
type: "LOAD_OK",
drivers: payload.data ?? [],
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
});
} catch {
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل بيانات السائقين. يرجى المحاولة مجدداً." });
}
}, []);
useEffect(() => {
loadDrivers(page, search);
}, [page, search, loadDrivers]);
// ── Create ────────────────────────────────────────────────────────────────
const createDriver = useCallback(
async (
payload: CreateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
): Promise<boolean> => {
try {
const token = getStoredToken();
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
if (hasFiles) {
const res = await driverService.createWithImages(payload, token);
const created = (res as unknown as { data: Driver }).data;
if (created?.id) {
await driverService.getById(created.id, token).catch(() => null);
}
} else {
await driverService.create(payload, token);
}
notify({ type: "success", message: "تم إضافة السائق بنجاح." });
await loadDrivers(page, search);
return true;
} catch (err) {
// ✅ بنعرض رسالة الخطأ كـ toast برضو، مش بس نرميها لفوق
const message = err instanceof Error ? err.message : "تعذّر إضافة السائق. يرجى المحاولة لاحقاً.";
notify({ type: "error", message });
throw err; // يفضل يترمي عشان DriverFormModal يعرضه جوه المودال كمان لو محتاج
}
},
[notify, loadDrivers, page, search],
);
// ── Update ────────────────────────────────────────────────────────────────
const updateDriver = useCallback(
async (
id: string,
payload: UpdateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
): Promise<boolean> => {
try {
const token = getStoredToken();
const hasFiles = payload.photo || payload.nationalPhoto || payload.driverCardPhoto;
let updatedDriver: Driver;
if (hasFiles) {
await driverService.updateWithImages(id, payload, token);
const fresh = await driverService.getById(id, token);
updatedDriver = (fresh as unknown as { data: Driver }).data;
} else {
const res = await driverService.update(id, payload, token);
updatedDriver = (res as unknown as { data: Driver }).data;
}
dispatch({ type: "UPDATE", driver: updatedDriver });
notify({ type: "success", message: "تم تحديث بيانات السائق بنجاح." });
return true;
} catch (err) {
// ✅ نفس الفكرة هنا — أي فشل في التحديث يبان كـ toast أحمر
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق. يرجى المحاولة لاحقاً.";
notify({ type: "error", message });
throw err;
}
},
[notify],
);
// ── Delete ────────────────────────────────────────────────────────────────
const deleteDriver = useCallback(
async (id: string): Promise<boolean> => {
try {
const token = getStoredToken();
await driverService.delete(id, token);
dispatch({ type: "DELETE", id });
notify({ type: "success", message: "تم حذف السائق بنجاح." });
return true;
} catch (err) {
const message = err instanceof Error ? err.message : "تعذّر حذف السائق.";
notify({ type: "error", message });
return false;
}
},
[notify],
);
// ── Search helper ─────────────────────────────────────────────────────────
const handleSearch = useCallback((q: string) => {
setSearch(q);
setPage(1);
}, []);
return {
...state,
page,
search,
setPage,
handleSearch,
clearError: () => dispatch({ type: "CLEAR_ERR" }),
createDriver,
updateDriver,
deleteDriver,
notification,
reload: () => loadDrivers(page, search),
};
}

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer } from "react"; import { useCallback, useEffect, useReducer } from "react";
import { orderService } from "../services/order.service"; import { orderService } from "@/src/services/order.service";
import type { import type {
Order, Order,
CreateOrderPayload, CreateOrderPayload,
UpdateOrderPayload, UpdateOrderPayload,
UpdateOrderStatusPayload, UpdateOrderStatusPayload,
} from "../types/order"; } from "@/src/types/order";
// ── State ───────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────
interface State { interface State {

Some files were not shown because too many files have changed in this diff Show More