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,3 +1,222 @@
|
||||
export default function AuditPage() {
|
||||
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>;
|
||||
"use client";
|
||||
|
||||
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() {
|
||||
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>;
|
||||
"use client";
|
||||
|
||||
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
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Spinner, Alert } from "@/Components/UI";
|
||||
import { CarFormModal } from "@/Components/car/CarFormModal";
|
||||
import { CarDetailPanel } from "@/Components/car/CarDetailPanel";
|
||||
import { CarDeleteModal } from "@/Components/car/CarDeleteModal";
|
||||
import { useCars, useCarMutations, useToast } from "@/hooks/useCars";
|
||||
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/types/car";
|
||||
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/types/car";
|
||||
import { Spinner, Alert } from "@/src/Components/UI";
|
||||
import { CarFormModal } from "@/src/Components/car/CarFormModal";
|
||||
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
|
||||
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
|
||||
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
|
||||
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
|
||||
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Alert } from "@/Components/UI";
|
||||
import { Toast } from "@/Components/Client/Toast";
|
||||
import { useClientAddresses } from "@/hooks/useClientAddresses";
|
||||
import { clientService } from "@/services/client.service";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import { Alert } from "@/src/Components/UI";
|
||||
import { Toast } from "@/src/Components/Client/Toast";
|
||||
import { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
||||
import { clientService } from "@/src/services/client.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import type {
|
||||
Client,
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
} from "@/types/client";
|
||||
import { AddressFormModal } from "@/Components/Client/Addressformmodal";
|
||||
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal";
|
||||
} from "@/src/types/client";
|
||||
import { AddressFormModal } from "@/src/Components/Client/Addressformmodal";
|
||||
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert } from "@/Components/UI";
|
||||
import { Toast } from "@/Components/Client/Toast";
|
||||
import { useClients } from "@/hooks/useClients";
|
||||
import type { Client, ClientFormData } from "@/types/client";
|
||||
import { ClientFormModal } from "@/Components/Client/Clientformmodal";
|
||||
import { ClientTable } from "@/Components/Client/Clienttable";
|
||||
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal";
|
||||
import { Alert } from "@/src/Components/UI";
|
||||
import { Toast } from "@/src/Components/Client/Toast";
|
||||
import { useClients } from "@/src/hooks/useClients";
|
||||
import type { Client, ClientFormData } from "@/src/types/client";
|
||||
import { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
|
||||
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
||||
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
||||
|
||||
export default function ClientsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -2,31 +2,22 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Spinner } from "@/Components/UI";
|
||||
import { DriverFormModal } from "@/Components/Driver/DriverFormModal";
|
||||
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal";
|
||||
import { DriverReportPanel } from "@/Components/Driver_Report/driverReport";
|
||||
import { Spinner } from "@/src/Components/UI";
|
||||
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
|
||||
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
|
||||
import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
|
||||
import { driverService } from "@/services";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import type {
|
||||
Driver,
|
||||
CreateDriverPayload,
|
||||
UpdateDriverPayload,
|
||||
} from "@/types/driver";
|
||||
import {
|
||||
DRIVER_STATUS_MAP,
|
||||
DRIVER_CARD_TYPE_MAP,
|
||||
NATIONAL_ID_TYPE_MAP,
|
||||
} from "@/types/driver";
|
||||
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
|
||||
import { DRIVER_STATUS_MAP, DRIVER_CARD_TYPE_MAP, NATIONAL_ID_TYPE_MAP } from "@/src/types/driver";
|
||||
import { PhotoCard } from "@/src/Components/Driver/DriverPhotos";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,40 +28,21 @@ function isExpiringSoon(iso?: string | null): boolean {
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
overflow: "hidden",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.25em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-hint)",
|
||||
fontWeight: 700,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
overflow: "hidden",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}>
|
||||
<div style={{
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: 0 }}>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
@@ -79,123 +51,59 @@ function SectionCard({
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
warn = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
warn?: boolean;
|
||||
function DetailRow({ label, value, mono = false, warn = false }: {
|
||||
label: string; value: string; mono?: boolean; warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
padding: "0.55rem 0",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
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}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
padding: "0.55rem 0", borderBottom: "1px solid var(--color-border)",
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
|
||||
<span 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>
|
||||
</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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DriverDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const driverId = params?.driverId as string;
|
||||
|
||||
const [driver, setDriver] = useState<Driver | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [driver, setDriver] = useState<Driver | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
// Reset avatar error when photo changes after an update
|
||||
useEffect(() => {
|
||||
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 [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const showToast = useCallback((type: "success" | "error", message: string) => {
|
||||
setToast({ type, message });
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
}, []);
|
||||
|
||||
// ── Load driver ───────────────────────────────────────────────────────────
|
||||
const loadDriver = useCallback(async () => {
|
||||
if (!driverId) return;
|
||||
@@ -212,27 +120,37 @@ export default function DriverDetailPage() {
|
||||
}
|
||||
}, [driverId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDriver();
|
||||
}, [loadDriver]);
|
||||
useEffect(() => { loadDriver(); }, [loadDriver]);
|
||||
|
||||
// ── Edit submit ───────────────────────────────────────────────────────────
|
||||
const handleEditSubmit = useCallback(
|
||||
async (
|
||||
payload: CreateDriverPayload | UpdateDriverPayload,
|
||||
_isNew: boolean,
|
||||
): Promise<boolean> => {
|
||||
async (payload: CreateDriverPayload | UpdateDriverPayload, _isNew: boolean): Promise<boolean> => {
|
||||
if (!driver) return false;
|
||||
try {
|
||||
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();
|
||||
showToast("success", "تم تحديث بيانات السائق بنجاح.");
|
||||
return true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق.";
|
||||
showToast("error", message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[driver, loadDriver],
|
||||
[driver, loadDriver, showToast],
|
||||
);
|
||||
|
||||
// ── Delete confirm ────────────────────────────────────────────────────────
|
||||
@@ -243,27 +161,19 @@ export default function DriverDetailPage() {
|
||||
const token = getStoredToken();
|
||||
await driverService.delete(driver.id, token);
|
||||
router.push("/dashboard/drivers");
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "تعذّر حذف السائق.";
|
||||
showToast("error", message);
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [driver, router]);
|
||||
}, [driver, router, showToast]);
|
||||
|
||||
// ── Status config ─────────────────────────────────────────────────────────
|
||||
const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null;
|
||||
|
||||
// ── Render: Loading ───────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
padding: "6rem 0",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<div 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>
|
||||
@@ -273,37 +183,14 @@ export default function DriverDetailPage() {
|
||||
// ── Render: Error ─────────────────────────────────────────────────────────
|
||||
if (error || !driver) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
margin: "4rem auto",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
<div style={{ 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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -315,35 +202,32 @@ export default function DriverDetailPage() {
|
||||
<>
|
||||
<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 ── */}
|
||||
<header
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
padding: "1.5rem 2rem",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}
|
||||
>
|
||||
{/* Back link */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.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)",
|
||||
}}
|
||||
>
|
||||
<header style={{
|
||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
|
||||
}}>
|
||||
<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">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7" />
|
||||
</svg>
|
||||
@@ -351,68 +235,35 @@ export default function DriverDetailPage() {
|
||||
</button>
|
||||
|
||||
<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={{
|
||||
width: 72,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 72, 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",
|
||||
}}>
|
||||
{driver.photoUrl && !avatarError ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={driver.photoUrl}
|
||||
alt={driver.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
<img 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)" }}>
|
||||
{driver.name.charAt(0)}
|
||||
</span>
|
||||
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>{driver.name.charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 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>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
|
||||
{driver.userName && (
|
||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
|
||||
@{driver.userName}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>@{driver.userName}</span>
|
||||
)}
|
||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
|
||||
{driver.phone}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>{driver.phone}</span>
|
||||
{statusConfig && (
|
||||
<span
|
||||
style={{
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${statusConfig.border}`,
|
||||
background: statusConfig.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: statusConfig.color,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
borderRadius: "var(--radius-full)", border: `1px solid ${statusConfig.border}`,
|
||||
background: statusConfig.bg, 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 }} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
@@ -421,45 +272,20 @@ export default function DriverDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
style={{
|
||||
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 type="button" onClick={() => setDeleteOpen(true)} style={{
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
style={{
|
||||
height: 40,
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<button type="button" onClick={() => setEditOpen(true)} style={{
|
||||
height: 40, 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">
|
||||
<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" />
|
||||
@@ -471,103 +297,58 @@ export default function DriverDetailPage() {
|
||||
</header>
|
||||
|
||||
{/* ── Content grid ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
{/* Personal Info */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
|
||||
<SectionCard title="البيانات الشخصية">
|
||||
<DetailRow label="الاسم الكامل" value={driver.name} />
|
||||
<DetailRow label="رقم الجوال" value={driver.phone} mono />
|
||||
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
|
||||
<DetailRow label="العنوان" value={driver.address ?? "—"} />
|
||||
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
|
||||
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
|
||||
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
|
||||
<DetailRow label="الاسم الكامل" value={driver.name} />
|
||||
<DetailRow label="رقم الجوال" value={driver.phone} mono />
|
||||
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
|
||||
<DetailRow label="العنوان" value={driver.address ?? "—"} />
|
||||
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
|
||||
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
|
||||
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
|
||||
</SectionCard>
|
||||
|
||||
{/* ID & GOSI */}
|
||||
<SectionCard title="الهوية والتأمينات">
|
||||
<DetailRow
|
||||
label="نوع الهوية"
|
||||
value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"}
|
||||
/>
|
||||
<DetailRow label="نوع الهوية" value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"} />
|
||||
<DetailRow label="رقم الهوية" value={driver.nationalId ?? "—"} mono />
|
||||
<DetailRow
|
||||
label="انتهاء الهوية"
|
||||
value={fmtDate(driver.nationalIdExpiry)}
|
||||
warn={isExpiringSoon(driver.nationalIdExpiry)}
|
||||
/>
|
||||
<DetailRow label="انتهاء الهوية" value={fmtDate(driver.nationalIdExpiry)} warn={isExpiringSoon(driver.nationalIdExpiry)} />
|
||||
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
|
||||
</SectionCard>
|
||||
|
||||
{/* License */}
|
||||
<SectionCard title="بيانات رخصة القيادة">
|
||||
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono />
|
||||
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
|
||||
<DetailRow
|
||||
label="انتهاء الرخصة"
|
||||
value={fmtDate(driver.licenseExpiry)}
|
||||
warn={isExpiringSoon(driver.licenseExpiry)}
|
||||
/>
|
||||
<DetailRow label="انتهاء الرخصة" value={fmtDate(driver.licenseExpiry)} warn={isExpiringSoon(driver.licenseExpiry)} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Driver Card */}
|
||||
<SectionCard title="بطاقة السائق">
|
||||
<DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono />
|
||||
<DetailRow
|
||||
label="نوع البطاقة"
|
||||
value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="انتهاء البطاقة"
|
||||
value={fmtDate(driver.driverCardExpiry)}
|
||||
warn={isExpiringSoon(driver.driverCardExpiry)}
|
||||
/>
|
||||
<DetailRow label="نوع البطاقة" value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"} />
|
||||
<DetailRow label="انتهاء البطاقة" value={fmtDate(driver.driverCardExpiry)} warn={isExpiringSoon(driver.driverCardExpiry)} />
|
||||
</SectionCard>
|
||||
|
||||
{/* System Info */}
|
||||
<SectionCard title="معلومات النظام">
|
||||
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono />
|
||||
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Status History */}
|
||||
{driver.statusHistory && driver.statusHistory.length > 0 && (
|
||||
<SectionCard title="سجل الحالات">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{driver.statusHistory.map((h) => {
|
||||
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
|
||||
return (
|
||||
<div
|
||||
key={h.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${s.border}`,
|
||||
background: s.bg,
|
||||
padding: "0.5rem 0.875rem",
|
||||
}}
|
||||
>
|
||||
<div key={h.id} style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
borderRadius: "var(--radius-md)", border: `1px solid ${s.border}`,
|
||||
background: s.bg, padding: "0.5rem 0.875rem",
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>
|
||||
{s.label}
|
||||
</span>
|
||||
{h.reason && (
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
|
||||
— {h.reason}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
|
||||
{h.reason && <span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>— {h.reason}</span>}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{fmtDate(h.createdAt)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(h.createdAt)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -576,31 +357,22 @@ export default function DriverDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Photos section (full width) ── */}
|
||||
<SectionCard title="الصور والمستندات">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1.25rem" }}>
|
||||
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
|
||||
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
|
||||
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
|
||||
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
|
||||
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة بطاقة السائق" />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Report section (full width) ── */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
padding: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
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} />
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* ── Edit modal ── */}
|
||||
{editOpen && (
|
||||
<DriverFormModal
|
||||
editDriver={driver}
|
||||
@@ -610,7 +382,6 @@ export default function DriverDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Delete modal ── */}
|
||||
{deleteOpen && (
|
||||
<DriverDeleteModal
|
||||
driver={driver}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Alert, Spinner } from "@/Components/UI";
|
||||
import { DriverFormModal } from "@/Components/Driver/DriverFormModal";
|
||||
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal";
|
||||
import { driverService } from "@/services";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import { useDrivers } from "@/hooks/useDriver";
|
||||
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/types/driver";
|
||||
import { DriverDetailPanel } from "@/Components/Driver/DriverDetailPanel";
|
||||
import { Alert, Spinner } from "@/src/Components/UI";
|
||||
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
|
||||
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
|
||||
import { useDrivers } from "@/src/hooks/useDriver";
|
||||
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver";
|
||||
import { DriverDetailPanel } from "@/src/Components/Driver/DriverDetailPanel";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,13 +43,28 @@ const thStyle: React.CSSProperties = {
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DriversPage() {
|
||||
const {
|
||||
drivers, loading, error, total, pages, page,
|
||||
search, setPage, handleSearch, clearError,
|
||||
deleteDriver, notification, reload,
|
||||
createDriver, updateDriver, deleteDriver,
|
||||
notification, reload,
|
||||
} = useDrivers();
|
||||
|
||||
// ── Panel / modal state ───────────────────────────────────────────────────
|
||||
@@ -60,6 +72,8 @@ export default function DriversPage() {
|
||||
const [formDriver, setFormDriver] = useState<Driver | null | "new">(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
// Bumped after a successful edit to force the detail panel to re-fetch
|
||||
const [panelRefreshKey, setPanelRefreshKey] = useState(0);
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
const handleEdit = useCallback((driver: Driver) => {
|
||||
@@ -77,34 +91,33 @@ export default function DriversPage() {
|
||||
payload: CreateDriverPayload | UpdateDriverPayload,
|
||||
isNew: boolean,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
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;
|
||||
let ok: boolean;
|
||||
|
||||
if (hasFiles) {
|
||||
await driverService.createWithImages(
|
||||
payload as Parameters<typeof driverService.createWithImages>[0],
|
||||
token,
|
||||
);
|
||||
} else {
|
||||
await driverService.create(payload as CreateDriverPayload, token);
|
||||
}
|
||||
} else {
|
||||
const id = (formDriver as Driver).id;
|
||||
await driverService.update(id, payload as UpdateDriverPayload, token);
|
||||
}
|
||||
reload();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
if (isNew) {
|
||||
ok = await createDriver(
|
||||
payload as CreateDriverPayload & {
|
||||
photo?: File;
|
||||
nationalPhoto?: File;
|
||||
driverCardPhoto?: File;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const id = (formDriver as Driver).id;
|
||||
ok = await updateDriver(
|
||||
id,
|
||||
payload as UpdateDriverPayload & {
|
||||
photo?: File;
|
||||
nationalPhoto?: File;
|
||||
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 () => {
|
||||
@@ -198,10 +211,7 @@ export default function DriversPage() {
|
||||
|
||||
{/* ── Notifications ── */}
|
||||
{notification && (
|
||||
<Alert
|
||||
type={notification.type}
|
||||
message={notification.message}
|
||||
/>
|
||||
<Alert type={notification.type} message={notification.message} />
|
||||
)}
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={clearError} />
|
||||
@@ -213,7 +223,7 @@ export default function DriversPage() {
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
|
||||
gridTemplateColumns: ROW_GRID_COLUMNS,
|
||||
...thStyle,
|
||||
}}
|
||||
>
|
||||
@@ -223,6 +233,7 @@ export default function DriversPage() {
|
||||
<span>انتهاء الرخصة</span>
|
||||
<span>انتهاء الهوية</span>
|
||||
<span>الحالة</span>
|
||||
<span>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -247,7 +258,7 @@ export default function DriversPage() {
|
||||
onClick={() => setSelectedDriverId(d.id)}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
|
||||
gridTemplateColumns: ROW_GRID_COLUMNS,
|
||||
alignItems: "center", gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
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)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")}
|
||||
>
|
||||
{/* Name + phone */}
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Branch */}
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
|
||||
|
||||
{/* Nationality */}
|
||||
<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)" }}>
|
||||
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
|
||||
</span>
|
||||
|
||||
{/* National ID expiry */}
|
||||
<span style={{ fontSize: 12, fontWeight: idWarn ? 600 : 400, color: idWarn ? "#D97706" : "var(--color-text-secondary)" }}>
|
||||
{idWarn && "⚠ "}{fmtDate((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry)}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
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 }} />
|
||||
{statusCfg.label}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -344,9 +388,10 @@ export default function DriversPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Detail panel ── */}
|
||||
{/* ── Detail panel — key forces re-fetch after successful edit ── */}
|
||||
{selectedDriverId && (
|
||||
<DriverDetailPanel
|
||||
key={`${selectedDriverId}-${panelRefreshKey}`}
|
||||
driverId={selectedDriverId}
|
||||
onClose={() => setSelectedDriverId(null)}
|
||||
onEdit={handleEdit}
|
||||
|
||||
@@ -11,8 +11,23 @@ export default function DashboardLayout({
|
||||
className="flex min-h-screen"
|
||||
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 />
|
||||
<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 />
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import { get } from "@/services/api";
|
||||
import { Spinner, Alert } from "@/Components/UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { get } from "@/src/services/api";
|
||||
import { Spinner, Alert } from "@/src/Components/UI";
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useDashboardSummary } from "../../hooks/useDashboardSummary";
|
||||
import { clearAuth, getStoredUser } from "../../lib/auth";
|
||||
import { Spinner, Alert } from "../../Components/UI";
|
||||
import { useDashboardSummary } from "@/src/hooks/useDashboardSummary";
|
||||
import { clearAuth, getStoredUser } from "@/src/lib/auth";
|
||||
import { Spinner, Alert } from "@/src/Components/UI";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
@@ -48,15 +48,15 @@ export default function DashboardPage() {
|
||||
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>
|
||||
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -111,23 +111,28 @@ export default function DashboardPage() {
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
</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}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</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]">
|
||||
<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>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9" }}>الرحلات النشطة</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>تقدم الرحلات</h2>
|
||||
<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", textAlign: "start" }}>تقدم الرحلات</h2>
|
||||
</div>
|
||||
<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 className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)" }}>{trip.tripNumber}</p>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>{trip.title}</p>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
||||
<span className="ltr-embed">{trip.tripNumber}</span>
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>{trip.title}</p>
|
||||
</div>
|
||||
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
|
||||
{trip.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</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)" }}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCD34D" }}>تنبيهات الالتزام</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>التجديدات القادمة</h2>
|
||||
<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", textAlign: "start" }}>التجديدات القادمة</h2>
|
||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{(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")}
|
||||
</div>
|
||||
))}
|
||||
{(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")}
|
||||
</div>
|
||||
))}
|
||||
{(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")}
|
||||
</div>
|
||||
))}
|
||||
{!(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>
|
||||
</article>
|
||||
@@ -184,21 +199,29 @@ export default function DashboardPage() {
|
||||
{/* ── Security + endpoints ── */}
|
||||
<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)" }}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD" }}>الأمان</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>لمحة الحساب</h2>
|
||||
<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", textAlign: "start" }}>لمحة الحساب</h2>
|
||||
<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)" }}>
|
||||
آخر تسجيل دخول: {data?.accountSecurity?.lastLogin || "—"}
|
||||
<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" }}>
|
||||
آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
|
||||
</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)" }}>
|
||||
بيانات الجهاز: {data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}
|
||||
<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" }}>
|
||||
{/*
|
||||
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>
|
||||
</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)" }}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7" }}>خريطة الخلفية</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>النقاط النهائية</h2>
|
||||
<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", textAlign: "start" }}>النقاط النهائية</h2>
|
||||
<ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
|
||||
{[
|
||||
"/v1/dashboard/summary — نظرة عامة تشغيلية",
|
||||
@@ -206,8 +229,18 @@ export default function DashboardPage() {
|
||||
"/v1/orders — دورة شحن الطلبات",
|
||||
"/v1/trip — تنظيم الرحلات",
|
||||
].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)" }}>
|
||||
{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)", textAlign: "start" }}>
|
||||
{/*
|
||||
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>
|
||||
))}
|
||||
</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() {
|
||||
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";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Spinner } from "@/Components/UI";
|
||||
import { TripFormModal } from "@/Components/Trip/Tripformmodal";
|
||||
import { TripDeleteModal } from "@/Components/Trip/Tripdeletemodal";
|
||||
import { TripReportPanel } from "@/Components/Trip_Report/Tripreportpanel";
|
||||
import { tripService } from "@/services/trip.service";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import type {
|
||||
Trip,
|
||||
CreateTripPayload,
|
||||
UpdateTripPayload,
|
||||
} from "@/types/trip";
|
||||
import { TRIP_STATUS_MAP } from "@/types/trip";
|
||||
import { Spinner } from "@/src/Components/UI";
|
||||
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
||||
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
|
||||
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
|
||||
|
||||
// ── API error extractor ────────────────────────────────────────────────────
|
||||
// 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,19 +42,26 @@ function fmtDate(iso?: string | null): string {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function fmtNumber(n?: number | string | null): string {
|
||||
if (n == null || n === "") return "—";
|
||||
return Number(n).toLocaleString("ar-SA");
|
||||
function fmtDateTime(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString("ar-SA", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function SectionCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -46,19 +72,23 @@ function SectionCard({ title, children }: { title: string; children: React.React
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}>
|
||||
<p style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.25em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-hint)",
|
||||
fontWeight: 700,
|
||||
margin: 0,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.25em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-hint)",
|
||||
fontWeight: 700,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
@@ -71,63 +101,70 @@ function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
warn = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
padding: "0.55rem 0",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
padding: "0.55rem 0",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
color: "var(--color-text-primary)",
|
||||
maxWidth: "60%",
|
||||
textAlign: "left",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stat card (للأعداد) ───────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = "var(--color-brand-600)",
|
||||
bg = "var(--color-brand-50, #EFF6FF)",
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
bg?: string;
|
||||
value: number | string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: bg,
|
||||
padding: "1rem 1.25rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.25rem",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", letterSpacing: "0.05em" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: 22, fontWeight: 700, color, fontFamily: "var(--font-mono)" }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 800, color, fontFamily: "var(--font-mono)" }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
@@ -141,14 +178,31 @@ export default function TripDetailPage() {
|
||||
const router = useRouter();
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Modal state ───────────────────────────────────────────────────────────
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = 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 ─────────────────────────────────────────────────────────────
|
||||
const loadTrip = useCallback(async () => {
|
||||
@@ -166,7 +220,9 @@ export default function TripDetailPage() {
|
||||
}
|
||||
}, [tripId]);
|
||||
|
||||
useEffect(() => { loadTrip(); }, [loadTrip]);
|
||||
useEffect(() => {
|
||||
loadTrip();
|
||||
}, [loadTrip]);
|
||||
|
||||
// ── Edit submit ───────────────────────────────────────────────────────────
|
||||
const handleEditSubmit = useCallback(
|
||||
@@ -177,299 +233,317 @@ export default function TripDetailPage() {
|
||||
if (!trip) return false;
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
await tripService.update(trip.id, payload as UpdateTripPayload, token);
|
||||
await loadTrip();
|
||||
const res = await tripService.update(trip.id, payload as UpdateTripPayload, token);
|
||||
const updated = (res as unknown as { data: Trip }).data;
|
||||
setTrip(updated);
|
||||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||||
return true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[trip, loadTrip],
|
||||
[trip, notify],
|
||||
);
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
// ── Delete confirm ────────────────────────────────────────────────────────
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!trip) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
await tripService.delete(trip.id, token);
|
||||
router.back();
|
||||
} catch {
|
||||
router.push("/dashboard/trips");
|
||||
} catch (err) {
|
||||
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") });
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
}, [trip, router]);
|
||||
}, [trip, router, notify]);
|
||||
|
||||
// ── Status config ─────────────────────────────────────────────────────────
|
||||
const statusConfig = trip ? TRIP_STATUS_MAP[trip.status] : null;
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
// ── Render: Loading ───────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
|
||||
<Spinner size="lg" />
|
||||
<div
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────────
|
||||
// ── Render: Error ─────────────────────────────────────────────────────────
|
||||
if (error || !trip) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", textAlign: "center" }}>
|
||||
<p style={{ fontSize: 14, color: "var(--color-danger)", marginBottom: "1rem" }}>
|
||||
{error ?? "الرحلة غير موجودة."}
|
||||
<div
|
||||
style={{
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
height: 38, padding: "0 1.25rem",
|
||||
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, cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
← العودة
|
||||
← رجوع
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render: Trip detail ────────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
<section style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
{/* ── Toast notification ── */}
|
||||
<Toast notification={notification} onDismiss={dismissNotification} />
|
||||
|
||||
{/* ── Header ── */}
|
||||
<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>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* Trip icon */}
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: "var(--radius-lg)",
|
||||
background: "var(--color-brand-50, #EFF6FF)",
|
||||
border: "1px solid var(--color-brand-200, #BFDBFE)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563EB" strokeWidth="1.8">
|
||||
<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" />
|
||||
<circle cx="18.5" cy="18.5" r="2.5" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* ── Page header ── */}
|
||||
<header
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
padding: "1.5rem 2rem",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}
|
||||
>
|
||||
{/* 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">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7" />
|
||||
</svg>
|
||||
العودة إلى قائمة الرحلات
|
||||
</button>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
{trip.title}
|
||||
</h1>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
|
||||
{trip.tripNumber}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
|
||||
{/* Icon + title */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
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>
|
||||
{statusConfig && (
|
||||
<span style={{
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${statusConfig.border}`,
|
||||
background: statusConfig.bg,
|
||||
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 }} />
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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" }}>
|
||||
{trip.title}
|
||||
</h1>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
|
||||
#{trip.tripNumber}
|
||||
</span>
|
||||
)}
|
||||
{trip.isDeleted && (
|
||||
<span style={{
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11, fontWeight: 700, color: "#DC2626",
|
||||
}}>
|
||||
محذوفة
|
||||
</span>
|
||||
)}
|
||||
{statusConfig && (
|
||||
<span
|
||||
style={{
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${statusConfig.border}`,
|
||||
background: statusConfig.bg,
|
||||
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 }} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
style={{
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
style={{
|
||||
height: 40, 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">
|
||||
<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>
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
style={{
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
style={{
|
||||
height: 40,
|
||||
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">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
{/* ── Stats row ── */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "1rem" }}>
|
||||
<StatCard
|
||||
label="المجمّع"
|
||||
value={String(trip.collectedCount)}
|
||||
color="#1E40AF"
|
||||
bg="#EFF6FF"
|
||||
/>
|
||||
<StatCard
|
||||
label="المُسلَّم"
|
||||
value={String(trip.deliveredCount)}
|
||||
color="#166534"
|
||||
bg="#DCFCE7"
|
||||
/>
|
||||
<StatCard
|
||||
label="المُرتجع"
|
||||
value={String(trip.returnedCount)}
|
||||
color="#92400E"
|
||||
bg="#FFFBEB"
|
||||
/>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: "0.75rem" }}>
|
||||
<StatCard label="المجمّع" value={trip.collectedCount} color="var(--color-brand-600)" />
|
||||
<StatCard label="المُسلَّم" value={trip.deliveredCount} color="#16A34A" />
|
||||
<StatCard label="المُرتجع" value={trip.returnedCount} color="#D97706" />
|
||||
<StatCard
|
||||
label="النقد المحصّل"
|
||||
value={fmtNumber(trip.totalCashCollected)}
|
||||
color="#581C87"
|
||||
bg="#FAF5FF"
|
||||
value={trip.totalCashCollected != null ? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س` : "—"}
|
||||
color="#7C3AED"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Content grid ── */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
{/* Trip Info */}
|
||||
<SectionCard title="بيانات الرحلة">
|
||||
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
|
||||
<DetailRow label="العنوان" value={trip.title} />
|
||||
<DetailRow label="الحالة" value={statusConfig?.label ?? trip.status} />
|
||||
<DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} />
|
||||
<DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} />
|
||||
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
|
||||
<DetailRow label="وقت البدء" value={fmtDate(trip.startTime)} />
|
||||
<DetailRow label="وقت الانتهاء" value={fmtDate(trip.endTime)} />
|
||||
<DetailRow label="سبب الإنهاء" value={trip.endReason ?? "—"} />
|
||||
{trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />}
|
||||
{trip.endReason && <DetailRow label="سبب الإنهاء" value={trip.endReason} warn />}
|
||||
</SectionCard>
|
||||
|
||||
{/* Driver */}
|
||||
<SectionCard title="السائق">
|
||||
{trip.driver ? (
|
||||
<>
|
||||
<DetailRow label="الاسم" value={trip.driver.name} />
|
||||
<DetailRow label="الجوال" value={trip.driver.phone} mono />
|
||||
<DetailRow label="البريد" value={trip.driver.email ?? "—"} />
|
||||
<DetailRow label="رقم الرخصة" value={trip.driver.licenseNumber ?? "—"} mono />
|
||||
<DetailRow label="رقم البطاقة" value={trip.driver.driverCardNumber ?? "—"} mono />
|
||||
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} />
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: 0 }}>لا توجد بيانات للسائق.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
{trip.driver && (
|
||||
<SectionCard title="بيانات السائق">
|
||||
<DetailRow label="الاسم" value={trip.driver.name} />
|
||||
<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.nationality ?? "—"} />
|
||||
<DetailRow label="رخصة القيادة" value={trip.driver.licenseNumber ?? "—"} mono />
|
||||
<DetailRow label="بطاقة السائق" value={trip.driver.driverCardNumber ?? "—"} mono />
|
||||
<DetailRow label="رقم GOSI" value={trip.driver.gosiNumber ?? "—"} mono />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Car */}
|
||||
<SectionCard title="السيارة">
|
||||
{trip.car ? (
|
||||
<>
|
||||
<DetailRow label="الماركة" value={trip.car.manufacturer} />
|
||||
<DetailRow label="الموديل" value={trip.car.model} />
|
||||
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} />
|
||||
<DetailRow label="اللون" value={trip.car.color ?? "—"} />
|
||||
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
|
||||
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono />
|
||||
<DetailRow label="نوع اللوحة" value={trip.car.plateType ?? "—"} />
|
||||
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono />
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: 0 }}>لا توجد بيانات للسيارة.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
{trip.car && (
|
||||
<SectionCard title="بيانات السيارة">
|
||||
<DetailRow label="الشركة" value={trip.car.manufacturer} />
|
||||
<DetailRow label="الموديل" value={trip.car.model} />
|
||||
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} />
|
||||
<DetailRow label="اللون" value={trip.car.color ?? "—"} />
|
||||
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
|
||||
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono />
|
||||
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* System Info */}
|
||||
<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)} />
|
||||
{trip.isDeleted && (
|
||||
<DetailRow label="تاريخ الحذف" value={fmtDate(trip.deletedAt)} />
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Notes (full width, if any) ── */}
|
||||
{trip.notes && (
|
||||
<SectionCard title="الملاحظات">
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-primary)", lineHeight: 1.7, margin: 0 }}>
|
||||
{trip.notes}
|
||||
</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 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",
|
||||
}}>
|
||||
{/* ── Report section (full width) ── */}
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,375 @@
|
||||
export default function TripsPage() {
|
||||
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>;
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "@/Components/UI";
|
||||
import { UserTable } from "@/Components/User/UserTable";
|
||||
import { UserFormModal } from "@/Components/User/UserFormModal";
|
||||
import { UserDetailModal } from "@/Components/User/UserDetailModal";
|
||||
import { DeleteConfirmModal } from "@/Components/User/DeleteConfirmModal";
|
||||
import { Toast } from "@/Components/User/Toast";
|
||||
import { useUsers } from "@/hooks/useUser";
|
||||
import type { User, UserFormData } from "@/types/user";
|
||||
import { Alert, Toast } from "@/src/Components/UI";
|
||||
import { UserTable } from "@/src/Components/User/UserTable";
|
||||
import { UserFormModal } from "@/src/Components/User/UserFormModal";
|
||||
import { UserDetailModal } from "@/src/Components/User/UserDetailModal";
|
||||
import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal";
|
||||
import { useUsers } from "@/src/hooks/useUser";
|
||||
import type { User, UserFormData } from "@/src/types/user";
|
||||
|
||||
export default function UsersPage() {
|
||||
// ── Modal state ─────────────────────────────────────────────────────────────
|
||||
@@ -41,9 +40,9 @@ export default function UsersPage() {
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget || deleting) return;
|
||||
setDeleting(true);
|
||||
console.log(setDeleting(true))
|
||||
|
||||
const ok = await deleteUser(deleteTarget.id);
|
||||
console.log(ok)
|
||||
|
||||
setDeleting(false);
|
||||
if (ok) setDeleteTarget(null);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user