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:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
35
app/api/proxy-image/[...imagePath]/route.ts
Normal file
35
app/api/proxy-image/[...imagePath]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
238
app/dashboard/role-permission/page.tsx
Normal file
238
app/dashboard/role-permission/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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)} ر.س`;
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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
2301
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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 }>;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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";
|
|
||||||
@@ -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 = {
|
||||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||||
@@ -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 = {
|
||||||
@@ -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 }) {
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
5
src/Components/Client/index.ts
Normal file
5
src/Components/Client/index.ts
Normal 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";
|
||||||
@@ -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;
|
||||||
@@ -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)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
إغلاق
|
إغلاق
|
||||||
@@ -582,4 +535,31 @@ export function DriverDetailPanel({
|
|||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
84
src/Components/Driver/DriverPhotos.tsx
Normal file
84
src/Components/Driver/DriverPhotos.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/Components/Driver/index.ts
Normal file
4
src/Components/Driver/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { DriverFormModal } from "./DriverFormModal";
|
||||||
|
export { DriverDeleteModal } from "./DriverDeleteModal";
|
||||||
|
export { DriverDetailPanel } from "./DriverDetailPanel";
|
||||||
|
export { PhotoCard } from "./DriverPhotos";
|
||||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -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"];
|
||||||
@@ -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;
|
||||||
732
src/Components/Trip/Tripformmodal.tsx
Normal file
732
src/Components/Trip/Tripformmodal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/Components/Trip/index.ts
Normal file
2
src/Components/Trip/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { TripFormModal } from "./Tripformmodal";
|
||||||
|
export { TripDeleteModal } from "./Tripdeletemodal";
|
||||||
120
src/Components/Trip_Report/Tripreportpanel.tsx
Normal file
120
src/Components/Trip_Report/Tripreportpanel.tsx
Normal 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
@@ -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> {
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 */
|
||||||
130
src/Components/UI/ModalProps.tsx
Normal file
130
src/Components/UI/ModalProps.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/Components/UI/PageHeader.tsx
Normal file
60
src/Components/UI/PageHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/Components/UI/Select.tsx
Normal file
54
src/Components/UI/Select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 */
|
||||||
64
src/Components/UI/Textarea.tsx
Normal file
64
src/Components/UI/Textarea.tsx
Normal 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
101
src/Components/UI/Toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 = {
|
||||||
@@ -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 }) {
|
||||||
5
src/Components/User/index.ts
Normal file
5
src/Components/User/index.ts
Normal 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";
|
||||||
@@ -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;
|
||||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
4
src/Components/car/index.ts
Normal file
4
src/Components/car/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { CarFormModal } from "./CarFormModal";
|
||||||
|
export { CarDetailPanel } from "./CarDetailPanel";
|
||||||
|
export { CarDeleteModal } from "./CarDeleteModal";
|
||||||
|
export { CarImageGallery } from "./CarImageGallery";
|
||||||
130
src/Components/role/DeleteRoleModal.tsx
Normal file
130
src/Components/role/DeleteRoleModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
375
src/Components/role/RoleDetailModal.tsx
Normal file
375
src/Components/role/RoleDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
510
src/Components/role/RoleFormModal.tsx
Normal file
510
src/Components/role/RoleFormModal.tsx
Normal 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)" }}>
|
||||||
|
لا توجد نتائج لـ "{searchPerm}"
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
src/Components/role/RoleTable.tsx
Normal file
300
src/Components/role/RoleTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/Components/role/RoleToast.tsx
Normal file
57
src/Components/role/RoleToast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/Components/role/index.ts
Normal file
5
src/Components/role/index.ts
Normal 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";
|
||||||
@@ -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,
|
||||||
@@ -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 {
|
||||||
45
src/hooks/useClientOrders.ts
Normal file
45
src/hooks/useClientOrders.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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
211
src/hooks/useDriver.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user