Replace SVGs with icons, restructure the table for CRUD operations across the project, standardize the application's display, and build a new template for the table's action buttons.
This commit is contained in:
@@ -182,9 +182,7 @@ export default function DriverDetailPage() {
|
||||
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
|
||||
}}>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()} className="mb-4 !px-0">
|
||||
<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>
|
||||
<i className="ti ti-arrow-left" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
العودة إلى قائمة السائقين
|
||||
</Button>
|
||||
|
||||
@@ -231,10 +229,7 @@ export default function DriverDetailPage() {
|
||||
حذف السائق
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setEditOpen(true)}>
|
||||
<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>
|
||||
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
تعديل السائق
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,96 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Spinner,
|
||||
ArchiveButton,
|
||||
ConfirmDialog,
|
||||
Toast,
|
||||
Button,
|
||||
IconBtn,
|
||||
} from "@/src/Components/UI";
|
||||
import { Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
|
||||
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
||||
import { DriverTable } from "@/src/Components/Driver/DriverTable";
|
||||
import { useDrivers } from "@/src/hooks/useDriver";
|
||||
import {
|
||||
CreateDriverPayload,
|
||||
Driver,
|
||||
DRIVER_STATUS_MAP,
|
||||
UpdateDriverPayload,
|
||||
} from "@/src/types/driver";
|
||||
import { DriverDetailPanel, DriverFormModal } from "@/src/Components/Driver";
|
||||
import Header from "@/src/Components/UI/Header";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function expirySoon(iso?: string | null): boolean {
|
||||
if (!iso) return false;
|
||||
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
overflow: "hidden",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: "0.75rem 1.5rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.2em",
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-muted)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
};
|
||||
|
||||
// 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";
|
||||
import { CreateDriverPayload, Driver, UpdateDriverPayload } from "@/src/types/driver";
|
||||
import { DriverDetailPanel, DriverFormModal , } from "@/src/Components/Driver";
|
||||
|
||||
// ── Page Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DriversPage() {
|
||||
const {
|
||||
drivers,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
pages,
|
||||
page,
|
||||
search,
|
||||
setPage,
|
||||
handleSearch,
|
||||
clearError,
|
||||
createDriver,
|
||||
updateDriver,
|
||||
deleteDriver,
|
||||
drivers, loading, error, total, pages, page,
|
||||
search, setPage, handleSearch, clearError,
|
||||
createDriver, updateDriver, deleteDriver,
|
||||
notification,
|
||||
clearNotification,
|
||||
} = useDrivers();
|
||||
|
||||
// ── Panel / modal state ───────────────────────────────────────────────────
|
||||
const [selectedDriverId, setSelectedDriverId] = useState<string | null>(null);
|
||||
const [formDriver, setFormDriver] = useState<Driver | null | "new">(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
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);
|
||||
const [panelRefreshKey, setPanelRefreshKey] = useState(0);
|
||||
// Archive browser modal open/closed
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
const handleEdit = useCallback((driver: Driver) => {
|
||||
@@ -148,290 +84,101 @@ export default function DriversPage() {
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
||||
>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<Header
|
||||
mainTitle="إدارة السائقين"
|
||||
title="السائقون"
|
||||
name="سائق"
|
||||
state={{ total }}
|
||||
search={search}
|
||||
setSearch={handleSearch}
|
||||
module=""
|
||||
setModule={() => {}}
|
||||
setPage={setPage}
|
||||
isAudit={false}
|
||||
onAdd={() => setFormDriver("new")}
|
||||
/>
|
||||
<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 style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
|
||||
<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>
|
||||
|
||||
{/* ── Notifications ── */}
|
||||
<Toast notification={notification} onDismiss={clearNotification} />
|
||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div style={cardStyle}>
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: ROW_GRID_COLUMNS,
|
||||
...thStyle,
|
||||
}}
|
||||
>
|
||||
<span>السائق</span>
|
||||
<span>الفرع</span>
|
||||
<span>الجنسية</span>
|
||||
<span>انتهاء الرخصة</span>
|
||||
<span>انتهاء الهوية</span>
|
||||
<span>الحالة</span>
|
||||
<span>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
) : drivers.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "4rem 0",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
لا توجد نتائج {search && `لـ "${search}"`}
|
||||
</p>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{drivers.map((d, i) => {
|
||||
const statusCfg =
|
||||
DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||
const licWarn = expirySoon(d.licenseExpiry);
|
||||
const idWarn = expirySoon(
|
||||
(d as Driver & { nationalIdExpiry?: string })
|
||||
.nationalIdExpiry,
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={d.id}
|
||||
onClick={() => setSelectedDriverId(d.id)}
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
{/* Search */}
|
||||
<div style={{ position: "relative", width: 288 }}>
|
||||
<i
|
||||
className="ti ti-search"
|
||||
aria-hidden="true"
|
||||
style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث بالاسم أو الهاتف..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: ROW_GRID_COLUMNS,
|
||||
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,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s",
|
||||
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)",
|
||||
}}
|
||||
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")
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{d.branch?.name ?? "—"}
|
||||
</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{d.nationality ?? "—"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: licWarn ? 600 : 400,
|
||||
color: licWarn
|
||||
? "#D97706"
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{licWarn && "⚠ "}
|
||||
{fmtDate(d.licenseExpiry)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: idWarn ? 600 : 400,
|
||||
color: idWarn
|
||||
? "#D97706"
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{idWarn && "⚠ "}
|
||||
{fmtDate(
|
||||
(d as Driver & { nationalIdExpiry?: string })
|
||||
.nationalIdExpiry,
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${statusCfg.border}`,
|
||||
background: statusCfg.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: statusCfg.color,
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: statusCfg.dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Inline row actions: edit / delete ──
|
||||
IconBtn already calls stopPropagation internally 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" }}>
|
||||
<IconBtn
|
||||
title="تعديل"
|
||||
color="var(--color-brand-600)"
|
||||
bg="var(--color-brand-50, #EFF6FF)"
|
||||
borderColor="var(--color-brand-200)"
|
||||
onClick={() => handleEdit(d)}
|
||||
>
|
||||
<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>
|
||||
</IconBtn>
|
||||
|
||||
<IconBtn
|
||||
title="حذف"
|
||||
color="#DC2626"
|
||||
bg="#FEF2F2"
|
||||
borderColor="#FECACA"
|
||||
onClick={() => handleDelete(d)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{pages > 1 && (
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
padding: "0.875rem 1.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||
{page}
|
||||
</strong>{" "}
|
||||
من{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||
{pages}
|
||||
</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
{/* Add button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormDriver("new")}
|
||||
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)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
<i className="ti ti-plus" style={{ fontSize: 15 }} aria-hidden="true" />
|
||||
إضافة سائق
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Notifications ── */}
|
||||
{notification && (
|
||||
<Alert type={notification.type} message={notification.message} />
|
||||
)}
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={clearError} />
|
||||
)}
|
||||
|
||||
{/* ── Table ── */}
|
||||
<DriverTable
|
||||
drivers={drivers}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={setPage}
|
||||
onRowClick={(d) => setSelectedDriverId(d.id)}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ── Detail panel — key forces re-fetch after successful edit ── */}
|
||||
@@ -466,7 +213,9 @@ export default function DriversPage() {
|
||||
/>
|
||||
|
||||
{/* ── Archive browser modal ── */}
|
||||
{archiveOpen && <ArchivedDrivers onClose={() => setArchiveOpen(false)} />}
|
||||
{archiveOpen && (
|
||||
<ArchivedDrivers onClose={() => setArchiveOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* ── Floating button to open the archive browser ── */}
|
||||
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||
|
||||
Reference in New Issue
Block a user