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:
@@ -39,10 +39,7 @@ function BranchIcon() {
|
||||
flexShrink: 0,
|
||||
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
|
||||
}}>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
|
||||
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
|
||||
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
|
||||
</svg>
|
||||
<i className="ti ti-building" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,134 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
// src/Components/Branch/BranchTable.tsx
|
||||
// MIGRATED to ReusableTable + ActionButtons — same props, same visual
|
||||
// result as the original hand-rolled version. No changes required in
|
||||
// app/dashboard/branches/page.tsx.
|
||||
|
||||
import { Badge, Button, ReusableTable, ActionButtons } from "../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface BranchTableProps {
|
||||
branches: Branch[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (branch: Branch) => void;
|
||||
onDelete: (branch: Branch) => void;
|
||||
onView: (branch: Branch) => void;
|
||||
onAddFirst: () => void;
|
||||
branches: Branch[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (branch: Branch) => void;
|
||||
onDelete: (branch: Branch) => void;
|
||||
onView: (branch: Branch) => void;
|
||||
onAddFirst: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function BranchTable({ branches, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: BranchTableProps) {
|
||||
export function BranchTable({
|
||||
branches, loading, search, page, pages,
|
||||
onEdit, onDelete, onView, onAddFirst, onPageChange,
|
||||
}: BranchTableProps) {
|
||||
const columns: TableColumn<Branch>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "اسم الفرع",
|
||||
width: "2fr",
|
||||
render: (b) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "city", header: "المدينة", width: "1.5fr", render: (b) => b.city || "—" },
|
||||
{
|
||||
key: "phone",
|
||||
header: "رقم الجوال",
|
||||
width: "1.5fr",
|
||||
render: (b) => (
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
{b.phone ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1fr",
|
||||
render: (b) => <Badge label={b.isActive ? "Active" : "Disabled"} color={b.isActive ? "green" : "red"} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الانشاء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (b) =>
|
||||
new Date(b.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px", ...thStyle }}>
|
||||
<span>الفرع</span>
|
||||
<span>المدينة</span>
|
||||
<span>الهاتف</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
) : branches.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="🏢"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
أضف أول فرع
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={branches}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
emptyIcon="🏢"
|
||||
emptyDescription={!search ? "No branches to display." : undefined}
|
||||
emptyAction={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
Add first branch
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
renderActions={(b) => (
|
||||
<ActionButtons
|
||||
itemLabel={b.name}
|
||||
onView={() => onView(b)}
|
||||
onEdit={() => onEdit(b)}
|
||||
onDelete={() => onDelete(b)}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{branches.map((b, i) => (
|
||||
<li key={b.id} style={{
|
||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 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, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>{b.phone ?? "—"}</span>
|
||||
<Badge label={b.isActive ? "نشط" : "معطل"} color={b.isActive ? "green" : "red"} />
|
||||
<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: 4 }}>
|
||||
{/* view */}
|
||||
<IconBtn title={`عرض ${b.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(b)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* edit */}
|
||||
<IconBtn title={`تعديل ${b.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(b)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* delete */}
|
||||
<IconBtn title={`حذف ${b.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(b)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* pagination */}
|
||||
{pages > 1 && (
|
||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -35,10 +35,7 @@ function BranchIcon() {
|
||||
flexShrink: 0,
|
||||
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
|
||||
}}>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
|
||||
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
|
||||
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
|
||||
</svg>
|
||||
<i className="ti ti-building" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,121 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button, IconBtn, Spinner } from "../../UI";
|
||||
// src/Components/Branch/archive/ArchivedBranchTable.tsx
|
||||
// MIGRATED to ReusableTable + ActionButtons. View-only, no row click in
|
||||
// the original.
|
||||
|
||||
import { Badge, ReusableTable, ActionButtons } from "../../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { ArchivedBranch } from "@/src/types/branch";
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface ArchivedBranchTableProps {
|
||||
branches: ArchivedBranch[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (branch: ArchivedBranch) => void;
|
||||
branches: ArchivedBranch[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (branch: ArchivedBranch) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function ArchivedBranchTable({ branches, loading, search, page, pages, onView, onPageChange }: ArchivedBranchTableProps) {
|
||||
const columns: TableColumn<ArchivedBranch>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "اسم الفرع",
|
||||
width: "2fr",
|
||||
render: (b) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "city", header: "المدينة", width: "1.5fr", render: (b) => b.city || "—" },
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1fr",
|
||||
render: (b) => <Badge label={b.isActive ? "Active" : "Disabled"} color={b.isActive ? "green" : "red"} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الانشاء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (b) => new Date(b.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
header: "تاريخ التعديل",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (b) => new Date(b.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
||||
<span>الفرع</span>
|
||||
<span>المدينة</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
) : branches.length === 0 ? (
|
||||
/* empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع في الأرشيف."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{branches.map((b, i) => (
|
||||
<li key={b.id} style={{
|
||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 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, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
|
||||
<Badge label={b.isActive ? "نشط" : "معطل"} color={b.isActive ? "green" : "red"} />
|
||||
<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>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(b.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view only — delete/restore not implemented yet */}
|
||||
<IconBtn title={`عرض ${b.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(b)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</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={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={branches}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
emptyDescription={search ? `No results for "${search}"` : "No archived branches."}
|
||||
renderActions={(b) => <ActionButtons itemLabel={b.name} onView={() => onView(b)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,7 @@ interface ArchivedBranchesModalProps {
|
||||
}
|
||||
|
||||
const SearchIcon = (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
);
|
||||
|
||||
export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// src/Components/Car_Maintanance/CarMaintananceDetailPanel.tsx
|
||||
// CHANGE: replaced CarMaintenanceDeleteModal with ConfirmDialog per Issue 1.
|
||||
// Description string rebuilt inline using fmtCost, matching the original
|
||||
// modal's wording exactly (see Issue 1 edge-case note on preserving fmtCost formatting).
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -252,9 +248,7 @@ export function CarMaintenanceDetailPanel({ carId, carLabel, onClose, onCarStatu
|
||||
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", flexShrink: 0 }}>
|
||||
<button type="button" onClick={() => setFormTarget(null)}
|
||||
style={{ width: "100%", height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
إضافة سجل صيانة
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
|
||||
import type { CarMaintenance } from "@/src/types/carMaintanance";
|
||||
|
||||
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)",
|
||||
};
|
||||
|
||||
import { IconBtn, ReusableTable } from "../../UI";
|
||||
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { CarMaintenance } from "@/src/types/carMaintanance";
|
||||
|
||||
interface ArchivedCarMaintenanceTableProps {
|
||||
records: CarMaintenance[];
|
||||
@@ -30,85 +22,78 @@ interface ArchivedCarMaintenanceTableProps {
|
||||
export function ArchivedCarMaintenanceTable({
|
||||
records, loading, search, page, pages, showCar = false, onView, onPageChange,
|
||||
}: ArchivedCarMaintenanceTableProps) {
|
||||
const columns = showCar ? "2fr 1fr 1fr 1fr 1fr 100px" : "2fr 1fr 1fr 1fr 100px";
|
||||
const columns: TableColumn<CarMaintenance>[] = [
|
||||
{
|
||||
key: "reason",
|
||||
header: "سبب الصيانة",
|
||||
width: "2fr",
|
||||
render: (r) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.reason}</p>
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>أضيف {fmtDateShort(r.createdAt)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// Only shown in the global (cross-car) archive view.
|
||||
...(showCar
|
||||
? [{
|
||||
key: "car",
|
||||
header: "المركبة",
|
||||
width: "1fr",
|
||||
render: (r: CarMaintenance) => (
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"}
|
||||
</span>
|
||||
),
|
||||
} as TableColumn<CarMaintenance>]
|
||||
: []),
|
||||
{
|
||||
key: "cost",
|
||||
header: "التكلفة",
|
||||
width: "1fr",
|
||||
render: (r) => (
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||
{fmtCost(r.cost)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "startAt", header: "تاريخ البدء", width: "1fr", render: (r) => (
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "endAt",
|
||||
header: "تاريخ الانتهاء",
|
||||
width: "1fr",
|
||||
render: (r) => (
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
{fmtDateShort(r.endAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: columns, ...thStyle }}>
|
||||
<span>سبب الصيانة</span>
|
||||
{showCar && <span>المركبة</span>}
|
||||
<span>التكلفة</span>
|
||||
<span>تاريخ البدء</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الانتهاء</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</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>
|
||||
) : records.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="🔧"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{records.map((r, i) => (
|
||||
<li key={r.id} style={{
|
||||
display: "grid", gridTemplateColumns: 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,
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.reason}</p>
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>أضيف {fmtDateShort(r.createdAt)}</p>
|
||||
</div>
|
||||
{showCar && (
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{r.car ? `${r.car.manufacturer} ${r.car.model}` : "—"}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||
{fmtCost(r.cost)}
|
||||
</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{fmtDateShort(r.startAt)}</span>
|
||||
<span style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
{fmtDateShort(r.endAt)}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
<IconBtn title={`عرض ${r.reason}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(r)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={records}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
actionsWidth="100px"
|
||||
onRowClick={onView}
|
||||
emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
|
||||
renderActions={(r) => (
|
||||
<IconBtn
|
||||
title={`عرض ${r.reason}`}
|
||||
color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
|
||||
onClick={() => onView(r)}
|
||||
>
|
||||
<i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
</IconBtn>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -51,9 +51,7 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
dir="rtl"
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Client/Clienttable.tsx
|
||||
// MIGRATED to ReusableTable — this is the one table in the project with
|
||||
// extra behavior (a click-to-expand detail panel below the row), so it
|
||||
// relies on ReusableTable's `renderExpanded` prop (added specifically to
|
||||
// cover this case) instead of the plain `renderActions`/`onRowClick` pair
|
||||
// every other table uses. AddressBadge and ClientDetailPanel are kept
|
||||
// exactly as in the original — purpose-built layouts with no shared-UI
|
||||
// equivalent, same rationale as the original file's comments.
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
import { IconBtn, ReusableTable, Button } from "../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { Client } from "@/src/types/client";
|
||||
|
||||
// ── Address count badge ────────────────────────────────────────────────────
|
||||
function AddressBadge({ count, onClick }: { count: number; onClick: () => void }) {
|
||||
function AddressBadge({
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const hasAddr = count > 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
title="إدارة العناوين"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
title="ادارة العناوين"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
@@ -29,25 +46,13 @@ function AddressBadge({ count, onClick }: { count: number; onClick: () => void }
|
||||
transition: "opacity 150ms",
|
||||
}}
|
||||
>
|
||||
{count} {count === 1 ? "عنوان" : "عناوين"}
|
||||
{/* Small arrow icon to hint navigation */}
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
{count} {count === 1 ? "address" : "العناوين"}
|
||||
<i className="ti ti-chevron-right" style={{ fontSize: 10 }} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client detail panel (inline expandable) ────────────────────────────────
|
||||
/**
|
||||
* ClientDetailPanel — shown below the clicked row.
|
||||
* Displays all client fields (name, email, phone, taxId, notes, createdAt)
|
||||
* and provides a direct link to the addresses sub-page.
|
||||
*
|
||||
* @param client The client whose details are rendered.
|
||||
* @param onGoAddresses Callback that navigates to /dashboard/clients/[id]/addresses.
|
||||
* @param onEdit Opens the edit modal for this client.
|
||||
*/
|
||||
// ── Client detail panel (the expandable content) ───────────────────────────
|
||||
function ClientDetailPanel({
|
||||
client,
|
||||
onGoAddresses,
|
||||
@@ -85,10 +90,8 @@ function ClientDetailPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
/* Slide-down visual treatment: left accent border in brand blue */
|
||||
borderRight: "3px solid var(--color-brand-600)",
|
||||
borderInlineStart: "3px solid var(--color-brand-600)",
|
||||
background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)",
|
||||
borderBottom: "1px solid #BFDBFE",
|
||||
padding: "1.25rem 1.5rem",
|
||||
@@ -98,7 +101,6 @@ function ClientDetailPanel({
|
||||
animation: "detailFadeIn 180ms ease",
|
||||
}}
|
||||
>
|
||||
{/* ── Top row: name + quick actions ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -138,36 +140,29 @@ function ClientDetailPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
|
||||
{/* Edit client */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" 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>
|
||||
تعديل البيانات
|
||||
<i className="ti ti-edit" style={{ fontSize: 12 }} aria-hidden="true" />
|
||||
تعديل التفاصيل
|
||||
</Button>
|
||||
|
||||
{/*
|
||||
* "Addresses" button — KEY FEATURE:
|
||||
* Navigates to /dashboard/clients/[clientId]/addresses
|
||||
*/}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); onGoAddresses(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onGoAddresses();
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<i className="ti ti-map-pin" style={{ fontSize: 12 }} aria-hidden="true" />
|
||||
العناوين
|
||||
{client.addresses?.length !== undefined && (
|
||||
<span
|
||||
@@ -187,7 +182,6 @@ function ClientDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Info grid ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -198,7 +192,7 @@ function ClientDetailPanel({
|
||||
}}
|
||||
>
|
||||
{infoItem(
|
||||
"البريد الإلكتروني",
|
||||
"البريد الالكتروني",
|
||||
<a
|
||||
href={`mailto:${client.email}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -207,27 +201,26 @@ function ClientDetailPanel({
|
||||
{client.email}
|
||||
</a>,
|
||||
)}
|
||||
{infoItem("الهاتف", client.phone)}
|
||||
{infoItem("رقم الجوال", client.phone)}
|
||||
{infoItem(
|
||||
"تاريخ الإضافة",
|
||||
new Date(client.createdAt).toLocaleDateString("ar-SA", {
|
||||
"تاريخ الانشاء",
|
||||
new Date(client.createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
)}
|
||||
{infoItem(
|
||||
"آخر تحديث",
|
||||
new Date(client.updatedAt).toLocaleDateString("ar-SA", {
|
||||
"اخر تحديث",
|
||||
new Date(client.updatedAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
)}
|
||||
{client.notes && infoItem("ملاحظات", client.notes)}
|
||||
{client.notes && infoItem("Notes", client.notes)}
|
||||
</div>
|
||||
|
||||
{/* Keyframe animation injected via a style tag (scoped) */}
|
||||
<style>{`
|
||||
@keyframes detailFadeIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
@@ -238,26 +231,6 @@ function ClientDetailPanel({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared card & header 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)",
|
||||
};
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientTableProps {
|
||||
clients: Client[];
|
||||
@@ -269,7 +242,6 @@ interface ClientTableProps {
|
||||
onDelete: (client: Client) => void;
|
||||
onAddFirst: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
/** Navigate to address management for a client */
|
||||
onManageAddresses: (client: Client) => void;
|
||||
}
|
||||
|
||||
@@ -286,311 +258,155 @@ export function ClientTable({
|
||||
onPageChange,
|
||||
onManageAddresses,
|
||||
}: ClientTableProps) {
|
||||
/**
|
||||
* expandedId — tracks which client row is currently expanded.
|
||||
* Clicking the same row again collapses it (toggle behaviour).
|
||||
*/
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const handleRowClick = (client: Client) => {
|
||||
// Toggle: collapse if already open, expand otherwise
|
||||
setExpandedId((prev) => (prev === client.id ? null : client.id));
|
||||
};
|
||||
const columns: TableColumn<Client>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "اسم العميل",
|
||||
width: "2fr",
|
||||
render: (c, _i, isExpanded) => (
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
<i
|
||||
className="ti ti-chevron-right"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
color: "var(--color-text-muted)",
|
||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||
transition: "transform 200ms",
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
{c.taxId && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{c.taxId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
header: "البريد الالكتروني",
|
||||
width: "1.5fr",
|
||||
render: (c) => (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{c.email}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
header: "رقم الجوال",
|
||||
width: "1.5fr",
|
||||
render: (c) => (
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{c.phone}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "addresses",
|
||||
header: "العناوين",
|
||||
width: "1fr",
|
||||
render: (c) => (
|
||||
<AddressBadge
|
||||
count={c.addresses?.length ?? 0}
|
||||
onClick={() => onManageAddresses(c)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الانشاء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (c) => (
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(c.createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* Column headers */}
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||
...thStyle,
|
||||
}}
|
||||
>
|
||||
<span>العميل</span>
|
||||
<span>البريد الإلكتروني</span>
|
||||
<span>الهاتف</span>
|
||||
<span>العناوين</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
padding: "4rem 0",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
/* Empty state */
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء لعرضهم."}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
أضف أول عميل
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={clients}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
actionsWidth="80px"
|
||||
emptyIcon="👥"
|
||||
emptyTitle={
|
||||
search ? `No results for "${search}"` : "No clients to display."
|
||||
}
|
||||
emptyAction={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
Add first client
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
renderActions={(c) => (
|
||||
<>
|
||||
<IconBtn
|
||||
title={`Edit ${c.name}`}
|
||||
color="#1D4ED8"
|
||||
bg="#EFF6FF"
|
||||
borderColor="#BFDBFE"
|
||||
onClick={() => onEdit(c)}
|
||||
>
|
||||
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
title={`Delete ${c.name}`}
|
||||
color="#DC2626"
|
||||
bg="#FEF2F2"
|
||||
borderColor="#FECACA"
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<i className="ti ti-trash" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
</IconBtn>
|
||||
</>
|
||||
)}
|
||||
renderExpanded={(c) => (
|
||||
<ClientDetailPanel
|
||||
client={c}
|
||||
onGoAddresses={() => onManageAddresses(c)}
|
||||
onEdit={() => onEdit(c)}
|
||||
/>
|
||||
) : (
|
||||
/* Data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{clients.map((c, i) => {
|
||||
const isExpanded = expandedId === c.id;
|
||||
return (
|
||||
<li key={c.id}>
|
||||
{/* ── Main row ── */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${c.name} — انقر لعرض التفاصيل`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleRowClick(c);
|
||||
}}
|
||||
onClick={() => handleRowClick(c)}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: isExpanded
|
||||
? "none"
|
||||
: "1px solid var(--color-border)",
|
||||
/* Highlight the expanded row with a brand-tinted background */
|
||||
background: isExpanded
|
||||
? "#EFF6FF"
|
||||
: i % 2 !== 0
|
||||
? "var(--color-surface-muted)"
|
||||
: "transparent",
|
||||
/* Left accent border when expanded (RTL: border-right in visual) */
|
||||
borderRight: isExpanded
|
||||
? "3px solid var(--color-brand-600)"
|
||||
: "3px solid transparent",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
transition: "background 120ms, border-color 120ms",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
{/* Chevron indicator — rotates when expanded */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--color-text-muted)",
|
||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||
transition: "transform 200ms",
|
||||
}}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</p>
|
||||
{c.taxId && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{c.taxId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{c.email}
|
||||
</span>
|
||||
|
||||
{/* Phone */}
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{c.phone}
|
||||
</span>
|
||||
|
||||
{/*
|
||||
* Address count badge — clicking it navigates directly to
|
||||
* /dashboard/clients/[clientId]/addresses (bypasses the detail panel).
|
||||
*/}
|
||||
<AddressBadge
|
||||
count={c.addresses?.length ?? 0}
|
||||
onClick={() => onManageAddresses(c)}
|
||||
/>
|
||||
|
||||
{/* Created at */}
|
||||
<span
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Row action buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconBtn
|
||||
title={`تعديل ${c.name}`}
|
||||
color="#1D4ED8"
|
||||
bg="#EFF6FF"
|
||||
borderColor="#BFDBFE"
|
||||
onClick={() => onEdit(c)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
title={`حذف ${c.name}`}
|
||||
color="#DC2626"
|
||||
bg="#FEF2F2"
|
||||
borderColor="#FECACA"
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* ── Expandable detail panel ──
|
||||
* Rendered inline below the row when isExpanded is true.
|
||||
* Contains full client metadata + "العناوين" navigation button.
|
||||
*
|
||||
* TEST: Click any row → panel slides in with client details.
|
||||
* Click "العناوين" → router.push to /dashboard/clients/[id]/addresses.
|
||||
* Click same row again → panel collapses.
|
||||
*/}
|
||||
{isExpanded && (
|
||||
<ClientDetailPanel
|
||||
client={c}
|
||||
onGoAddresses={() => onManageAddresses(c)}
|
||||
onEdit={() => onEdit(c)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
padding: "0.875rem 1.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>{page}</strong>{" "}
|
||||
من{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{
|
||||
label: "السابق",
|
||||
action: () => onPageChange(Math.max(1, page - 1)),
|
||||
disabled: page === 1,
|
||||
},
|
||||
{
|
||||
label: "التالي",
|
||||
action: () => onPageChange(Math.min(pages, page + 1)),
|
||||
disabled: page === pages,
|
||||
},
|
||||
].map((btn) => (
|
||||
<Button
|
||||
key={btn.label}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={btn.action}
|
||||
disabled={btn.disabled}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
// src/Components/Client/archive/ArchivedClientTable.tsx
|
||||
// MIGRATED to ReusableTable + ActionButtons. View-only, no row click in
|
||||
// the original.
|
||||
|
||||
import { Badge, ReusableTable, ActionButtons } from "../../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { ArchivedClient } from "@/src/types/client";
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface ArchivedClientTableProps {
|
||||
clients: ArchivedClient[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (client: ArchivedClient) => void;
|
||||
clients: ArchivedClient[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (client: ArchivedClient) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function ArchivedClientTable({ clients, loading, search, page, pages, onView, onPageChange }: ArchivedClientTableProps) {
|
||||
const columns: TableColumn<ArchivedClient>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "اسم العميل",
|
||||
width: "2fr",
|
||||
render: (c) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{c.name}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
header: "البريد الالكتروني",
|
||||
width: "1.5fr",
|
||||
render: (c) => <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1fr",
|
||||
render: (c) => <Badge label={c.isActive ? "Active" : "Disabled"} color={c.isActive ? "green" : "red"} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الانشاء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (c) => new Date(c.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
header: "تاريخ التعديل",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (c) => new Date(c.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
||||
<span>العميل</span>
|
||||
<span>البريد الإلكتروني</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
) : clients.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{clients.map((c, i) => (
|
||||
<li key={c.id} style={{
|
||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 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 }}>{c.name}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
|
||||
</div>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>
|
||||
<Badge label={c.isActive ? "نشط" : "معطل"} color={c.isActive ? "green" : "red"} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(c.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view only — delete/restore not implemented yet, matches archived users */}
|
||||
<IconBtn title={`عرض ${c.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(c)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* pagination */}
|
||||
{pages > 1 && (
|
||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={clients}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
emptyIcon="👥"
|
||||
emptyDescription={search ? `No results for "${search}"` : "No archived clients."}
|
||||
renderActions={(c) => <ActionButtons itemLabel={c.name} onView={() => onView(c)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,9 +42,7 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
dir="rtl"
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/Components/Client/index.ts
|
||||
// CHANGE: removed DeleteConfirmModal export (Issue 1).
|
||||
export { ClientFormModal } from "./Clientformmodal";
|
||||
export { ClientTable } from "./Clienttable";
|
||||
export { ClientTable } from "./ClientTable";
|
||||
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
|
||||
import { Badge, IconBtn, ReusableTable } from "../../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { ArchivedClientAddress } from "@/src/types/client_adresses";
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
type Row = ArchivedClientAddress & { id: string };
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface ArchivedClientAddressesTableProps {
|
||||
@@ -25,73 +18,87 @@ interface ArchivedClientAddressesTableProps {
|
||||
|
||||
// ── main table — no pagination footer: the endpoint returns the full set ─────
|
||||
export function ArchivedClientAddressesTable({ addresses, loading, search, onView }: ArchivedClientAddressesTableProps) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px", ...thStyle }}>
|
||||
<span>النوع</span>
|
||||
<span>العنوان</span>
|
||||
<span>جهة الاتصال</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
const rows: Row[] = addresses.map((a) => ({ ...a, id: a._id }));
|
||||
|
||||
{/* loading 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>
|
||||
const columns: TableColumn<Row>[] = [
|
||||
{
|
||||
key: "type",
|
||||
header: "النوع",
|
||||
width: "1.5fr",
|
||||
render: (a) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{a.label}</p>
|
||||
{a.branchName && (
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p>
|
||||
)}
|
||||
</div>
|
||||
) : addresses.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="📍"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "address",
|
||||
header: "العنوان",
|
||||
width: "2fr",
|
||||
render: (a) => (
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{a.details.street}، {a.details.city}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "contact",
|
||||
header: "جهة الاتصال",
|
||||
width: "1.2fr",
|
||||
render: (a) => (
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
{a.contactPerson?.name ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1fr",
|
||||
render: (a) => (
|
||||
<Badge
|
||||
label={a.isValidated ? "موثّق" : "غير موثّق"}
|
||||
color={a.isValidated ? "green" : "slate"}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{addresses.map((a, i) => (
|
||||
<li key={a._id} style={{
|
||||
display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 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 }}>{a.label}</p>
|
||||
{a.branchName && (
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{a.branchName}</p>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{a.details.street}، {a.details.city}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
{a.contactPerson?.name ?? "—"}
|
||||
</span>
|
||||
<Badge
|
||||
label={a.isValidated ? "موثّق" : "غير موثّق"}
|
||||
color={a.isValidated ? "green" : "slate"}
|
||||
/>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view only — matches archived users/clients: no delete/restore yet */}
|
||||
<IconBtn title={`عرض ${a.label}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(a)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الإنشاء",
|
||||
width: "1fr",
|
||||
render: (a) => (
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={1}
|
||||
pages={1}
|
||||
onPageChange={() => {}}
|
||||
actionsWidth="100px"
|
||||
onRowClick={onView}
|
||||
emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
|
||||
renderActions={(a) => (
|
||||
// view only — matches archived users/clients: no delete/restore yet
|
||||
<IconBtn
|
||||
title={`عرض ${a.label}`}
|
||||
color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
|
||||
onClick={() => onView(a)}
|
||||
>
|
||||
<i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
</IconBtn>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -46,9 +46,7 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
dir="rtl"
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -120,18 +120,7 @@ function PhotoCard({ url, label }: { url?: string | null; label: string }) {
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
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>
|
||||
<i className="ti ti-photo" style={{ fontSize: 28, color: "var(--color-text-hint)" }} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
{url && !imgError && (
|
||||
@@ -456,10 +445,7 @@ export function DriverDetailPanel({
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="secondary" onClick={() => onEdit(driver)}>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -79,18 +79,7 @@ export function PhotoCard({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
<i className="ti ti-photo" style={{ fontSize: 32, color: "var(--color-text-hint)" }} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
{url && !imgError && (
|
||||
@@ -110,4 +99,4 @@ export function PhotoCard({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
146
src/Components/Driver/DriverTable.tsx
Normal file
146
src/Components/Driver/DriverTable.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Driver/DriverTable.tsx
|
||||
// NEW — previously this table was written inline inside
|
||||
// app/dashboard/drivers/page.tsx (the only model whose table wasn't
|
||||
// already split into its own component). Extracted here using
|
||||
// ReusableTable + ActionButtons, matching every other *Table.tsx in the
|
||||
// project. Same columns, same warn-highlighting for expiring
|
||||
// license/national-ID dates, same row-click-to-view / edit+delete icons
|
||||
// behavior as the original inline version.
|
||||
//
|
||||
// Integration: in app/dashboard/drivers/page.tsx, replace the entire
|
||||
// `<div style={cardStyle}>...</div>` table block (and its `fmtDate` /
|
||||
// `expirySoon` / `cardStyle` / `thStyle` / `ROW_GRID_COLUMNS` /
|
||||
// `iconBtnBase` helpers, now unused) with:
|
||||
//
|
||||
// <DriverTable
|
||||
// drivers={drivers}
|
||||
// loading={loading}
|
||||
// search={search}
|
||||
// page={page}
|
||||
// pages={pages}
|
||||
// onPageChange={setPage}
|
||||
// onRowClick={(d) => setSelectedDriverId(d.id)}
|
||||
// onEdit={handleEdit}
|
||||
// onDelete={handleDelete}
|
||||
// />
|
||||
|
||||
import { ReusableTable, ActionButtons } from "../UI";
|
||||
import { DRIVER_STATUS_MAP } from "@/src/types/driver";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { Driver } from "@/src/types/driver";
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("en-US", { 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;
|
||||
}
|
||||
|
||||
interface DriverTableProps {
|
||||
drivers: Driver[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onPageChange: (p: number) => void;
|
||||
onRowClick: (driver: Driver) => void;
|
||||
onEdit: (driver: Driver) => void;
|
||||
onDelete: (driver: Driver) => void;
|
||||
}
|
||||
|
||||
export function DriverTable({
|
||||
drivers, loading, search, page, pages,
|
||||
onPageChange, onRowClick, onEdit, onDelete,
|
||||
}: DriverTableProps) {
|
||||
const columns: TableColumn<Driver>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "السائقين",
|
||||
width: "2fr",
|
||||
render: (d) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{ key: "branch", header: "الفرع", width: "1.2fr", render: (d) => d.branch?.name ?? "—" },
|
||||
{ key: "nationality", header: "الجنسية", width: "1fr", render: (d) => d.nationality ?? "—" },
|
||||
{
|
||||
key: "licenseExpiry",
|
||||
header: "تاريخ انتهاء الرخصة",
|
||||
width: "1.1fr",
|
||||
render: (d) => {
|
||||
const warn = expirySoon(d.licenseExpiry);
|
||||
return (
|
||||
<span style={{ fontSize: 12, fontWeight: warn ? 600 : 400, color: warn ? "#D97706" : "var(--color-text-secondary)" }}>
|
||||
{warn && "⚠ "}{fmtDate(d.licenseExpiry)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "nationalIdExpiry",
|
||||
header: "تاريخ انتهاء الهوية الوطنية",
|
||||
width: "1.1fr",
|
||||
render: (d) => {
|
||||
const idExpiry = (d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry;
|
||||
const warn = expirySoon(idExpiry);
|
||||
return (
|
||||
<span style={{ fontSize: 12, fontWeight: warn ? 600 : 400, color: warn ? "#D97706" : "var(--color-text-secondary)" }}>
|
||||
{warn && "⚠ "}{fmtDate(idExpiry)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1fr",
|
||||
render: (d) => {
|
||||
const s = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${s.border}`,
|
||||
background: s.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11, fontWeight: 600, color: s.color,
|
||||
width: "fit-content",
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={drivers}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
actionsWidth="0.8fr"
|
||||
onRowClick={onRowClick}
|
||||
emptyDescription={search ? `No results for "${search}"` : undefined}
|
||||
renderActions={(d) => (
|
||||
<ActionButtons
|
||||
itemLabel={d.name}
|
||||
onEdit={() => onEdit(d)}
|
||||
onDelete={() => onDelete(d)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
|
||||
|
||||
import { ReusableTable, ActionButtons } from "../../UI";
|
||||
import { DRIVER_STATUS_MAP } from "@/src/types/driver";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { ArchivedDriver } from "@/src/types/driver";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: colors come dynamically from
|
||||
// DRIVER_STATUS_MAP (multiple statuses, each with its own color+dot), which
|
||||
// the shared Badge component's fixed palette doesn't cover.
|
||||
function StatusBadge({ status }: { status: ArchivedDriver["status"] }) {
|
||||
const cfg = DRIVER_STATUS_MAP[status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${cfg.border}`, background: cfg.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: cfg.color }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: cfg.dot }} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
const ROW_GRID_COLUMNS = "2fr 1.5fr 1fr 1fr 1fr 100px";
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface ArchivedDriverTableProps {
|
||||
drivers: ArchivedDriver[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (driver: ArchivedDriver) => void;
|
||||
drivers: ArchivedDriver[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (driver: ArchivedDriver) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the paginated table of archived drivers, including loading,
|
||||
* empty, and error-free states. Mirrors ArchivedUserTable for consistency.
|
||||
*/
|
||||
export function ArchivedDriverTable({ drivers, loading, search, page, pages, onView, onPageChange }: ArchivedDriverTableProps) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}>
|
||||
<span>الاسم</span>
|
||||
<span>اسم المستخدم</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
const columns: TableColumn<ArchivedDriver>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "الاسم",
|
||||
width: "2fr",
|
||||
render: (d) => (
|
||||
<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>
|
||||
) : drivers.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{drivers.map((d, i) => (
|
||||
<li key={d.id} onClick={() => onView(d)} 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",
|
||||
}}>
|
||||
<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={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
|
||||
<StatusBadge status={d.status} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(d.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(d.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view only — delete/restore not implemented yet, same as users */}
|
||||
<IconBtn title={`عرض ${d.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(d)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "userName",
|
||||
header: "اسم السائق",
|
||||
width: "1.5fr",
|
||||
render: (d) => (
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1fr",
|
||||
render: (d) => {
|
||||
const s = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الانشاء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (d) => new Date(d.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
header: "تاريخ التعديل",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (d) => new Date(d.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={drivers}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
onRowClick={onView}
|
||||
emptyIcon="🗄️"
|
||||
emptyDescription={search ? `No results for "${search}"` : "No archived drivers."}
|
||||
renderActions={(d) => <ActionButtons itemLabel={d.name} onView={() => onView(d)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,18 +11,7 @@ interface ArchivedDriversProps {
|
||||
}
|
||||
|
||||
const searchIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -222,10 +222,7 @@ export function OrderDetailPanel({
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand-600)" strokeWidth="2">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||
</svg>
|
||||
<i className="ti ti-package" style={{ fontSize: 24, color: "var(--color-brand-600)" }} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -460,10 +457,7 @@ export function OrderDetailPanel({
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="secondary" onClick={() => onEdit(order)}>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -372,10 +372,7 @@ export function OrderFormModal({
|
||||
))}
|
||||
</Select>
|
||||
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
|
||||
<svg width="11" height="11" 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>
|
||||
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
|
||||
إنشاء عميل جديد
|
||||
</Link>
|
||||
</div>
|
||||
@@ -418,10 +415,7 @@ export function OrderFormModal({
|
||||
))}
|
||||
</Select>
|
||||
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
|
||||
<svg width="11" height="11" 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>
|
||||
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
|
||||
إنشاء رحلة جديدة
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,60 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../UI";
|
||||
import type { Order } from "@/src/types/order";
|
||||
import { ReusableTable, ActionButtons } from "../UI";
|
||||
import { ORDER_STATUS_MAP } from "./OrderDetailModel";
|
||||
|
||||
// ── Helpers — copied verbatim from app/dashboard/drivers/page.tsx ───────
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { Order } from "@/src/types/order";
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
year: "numeric", month: "short", day: "numeric",
|
||||
});
|
||||
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function fmtAmount(n?: number | null): string {
|
||||
if (n == null) return "—";
|
||||
return `${n.toFixed(2)} ر.س`;
|
||||
return `${n.toFixed(2)} SAR`;
|
||||
}
|
||||
|
||||
// ── Styles — identical tokens/values to the Drivers page, per the
|
||||
// "no new styles" constraint; only column widths differ to fit Order's
|
||||
// field set (shipment / recipient / client / amount / status / actions).
|
||||
|
||||
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 = "1.6fr 1.6fr 1.2fr 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,
|
||||
};
|
||||
|
||||
interface OrderTableProps {
|
||||
orders: Order[];
|
||||
loading: boolean;
|
||||
@@ -68,181 +28,86 @@ interface OrderTableProps {
|
||||
}
|
||||
|
||||
export function OrderTable({
|
||||
orders,
|
||||
loading,
|
||||
search,
|
||||
page,
|
||||
pages,
|
||||
setPage,
|
||||
onRowClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
orders, loading, search, page, pages,
|
||||
setPage, onRowClick, onEdit, onDelete,
|
||||
}: OrderTableProps) {
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
const columns: TableColumn<Order>[] = [
|
||||
{
|
||||
key: "shipmentNumber",
|
||||
header: "رقم الشحنة #",
|
||||
width: "1.6fr",
|
||||
render: (o) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB", margin: 0 }}>{o.shipmentNumber}</p>
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(o.createdAt)}</p>
|
||||
</div>
|
||||
) : orders.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 }}>
|
||||
{orders.map((o, i) => {
|
||||
const statusCfg = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={o.id}
|
||||
onClick={() => onRowClick(o.id)}
|
||||
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",
|
||||
}}
|
||||
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, fontFamily: "var(--font-mono)", color: "#2563EB", margin: 0 }}>{o.shipmentNumber}</p>
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(o.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span>
|
||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
|
||||
{fmtAmount(o.totalPrice)}
|
||||
</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>
|
||||
|
||||
{/* ── 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(); onEdit(o); }}
|
||||
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(); onDelete(o); }}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "recipient",
|
||||
header: "المستلم",
|
||||
width: "1.6fr",
|
||||
render: (o) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.client?.name ?? "—" },
|
||||
{
|
||||
key: "amount",
|
||||
header: "الكمية",
|
||||
width: "1.1fr",
|
||||
render: (o) => (
|
||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
|
||||
{fmtAmount(o.totalPrice)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1fr",
|
||||
render: (o) => {
|
||||
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${s.border}`,
|
||||
background: s.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11, fontWeight: 600, color: s.color,
|
||||
width: "fit-content",
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
|
||||
{s.label}
|
||||
</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(pages, p + 1)), disabled: page === 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={orders}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={setPage}
|
||||
actionsWidth="0.8fr"
|
||||
onRowClick={(o) => onRowClick(o.id)}
|
||||
emptyDescription={search ? `No results for "${search}"` : undefined}
|
||||
renderActions={(o) => (
|
||||
<ActionButtons
|
||||
itemLabel={o.shipmentNumber}
|
||||
onEdit={() => onEdit(o)}
|
||||
onDelete={() => onDelete(o)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import { ReusableTable, ActionButtons } from "../../UI";
|
||||
import { ORDER_STATUS_MAP } from "../OrderDetailModel";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { ArchivedOrder } from "@/src/types/order";
|
||||
|
||||
|
||||
function fmtAmount(n?: string | number | null): string {
|
||||
if (n == null) return "—";
|
||||
const num = typeof n === "string" ? parseFloat(n) : n;
|
||||
if (Number.isNaN(num)) return "—";
|
||||
return `${num.toFixed(2)} ر.س`;
|
||||
return `${num.toFixed(2)} SAR`;
|
||||
}
|
||||
|
||||
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)",
|
||||
};
|
||||
const ROW_GRID = "1.6fr 1.6fr 1fr 1fr 100px";
|
||||
|
||||
interface ArchivedOrderTableProps {
|
||||
orders: ArchivedOrder[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (order: ArchivedOrder) => void;
|
||||
orders: ArchivedOrder[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (order: ArchivedOrder) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
export function ArchivedOrderTable({ orders, loading, search, page, pages, onView, onPageChange }: ArchivedOrderTableProps) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID, ...thStyle }}>
|
||||
<span>رقم الشحنة</span>
|
||||
<span>المستلم</span>
|
||||
<span>المبلغ</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</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>
|
||||
const columns: TableColumn<ArchivedOrder>[] = [
|
||||
{
|
||||
key: "shipmentNumber",
|
||||
header: "رقم الشحنة #",
|
||||
width: "1.6fr",
|
||||
render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>{o.shipmentNumber}</span>,
|
||||
},
|
||||
{
|
||||
key: "recipient",
|
||||
header: "المستلم",
|
||||
width: "1.6fr",
|
||||
render: (o) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{orders.map((o, i) => {
|
||||
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
||||
return (
|
||||
<li key={o.id} style={{
|
||||
display: "grid", gridTemplateColumns: ROW_GRID,
|
||||
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,
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>
|
||||
{o.shipmentNumber}
|
||||
</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
|
||||
</div>
|
||||
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>
|
||||
{fmtAmount(o.subTotal)}
|
||||
</span>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`,
|
||||
background: s.bg, padding: "0.2rem 0.625rem",
|
||||
fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content",
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<IconBtn title={`عرض ${o.shipmentNumber}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(o)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
header: "الكمية",
|
||||
width: "1fr",
|
||||
render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>{fmtAmount(o.subTotal)}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1fr",
|
||||
render: (o) => {
|
||||
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content" }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={orders}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
emptyIcon="🗄️"
|
||||
emptyDescription={search ? `No results for "${search}"` : "No archived orders."}
|
||||
renderActions={(o) => <ActionButtons itemLabel={o.shipmentNumber} onView={() => onView(o)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,18 +12,7 @@ interface ArchivedOrdersModalProps {
|
||||
}
|
||||
|
||||
const searchIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
);
|
||||
|
||||
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/TripDetailModal.tsx
|
||||
// New — split out of the old single-file Trip module, mirroring
|
||||
// UserDetailModal.tsx / ArchivedDriverDetailModal.tsx for consistency.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Modal, Spinner } from "../UI";
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/TripTable.tsx
|
||||
// New — split out of the old single-file Trip module, mirroring
|
||||
// UserTable.tsx for consistency.
|
||||
// MIGRATED to ReusableTable + ActionButtons — same props, same visual
|
||||
// result as the original. StatusBadge kept custom since colors come
|
||||
// dynamically from TRIP_STATUS_MAP (per-status color+dot).
|
||||
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
import { Button, ReusableTable, ActionButtons } from "../UI";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: colors come dynamically from
|
||||
// TRIP_STATUS_MAP (per-status color+dot), which Badge's fixed palette
|
||||
// doesn't cover — mirrors DriverTable's StatusBadge.
|
||||
function StatusBadge({ status }: { status: Trip["status"] }) {
|
||||
const s = TRIP_STATUS_MAP[status];
|
||||
return (
|
||||
@@ -22,151 +20,84 @@ function StatusBadge({ status }: { status: Trip["status"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
const ROW_GRID_COLUMNS = "2fr 1.3fr 1.3fr 1fr 1fr 1fr 100px";
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
||||
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface TripTableProps {
|
||||
trips: Trip[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (trip: Trip) => void;
|
||||
onDelete: (trip: Trip) => void;
|
||||
onView: (trip: Trip) => void;
|
||||
onAddFirst: () => void;
|
||||
trips: Trip[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (trip: Trip) => void;
|
||||
onDelete: (trip: Trip) => void;
|
||||
onView: (trip: Trip) => void;
|
||||
onAddFirst: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function TripTable({ trips, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: TripTableProps) {
|
||||
export function TripTable({
|
||||
trips, loading, search, page, pages,
|
||||
onEdit, onDelete, onView, onAddFirst, onPageChange,
|
||||
}: TripTableProps) {
|
||||
const columns: TableColumn<Trip>[] = [
|
||||
{
|
||||
key: "title",
|
||||
header: "الرحلة",
|
||||
width: "2fr",
|
||||
render: (t) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{t.tripNumber}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "driver", header: "السائق", width: "1.3fr", render: (t) => t.driver?.name ?? "—" },
|
||||
{
|
||||
key: "car",
|
||||
header: "السيارة",
|
||||
width: "1.3fr",
|
||||
render: (t) => (t.car ? `${t.car.manufacturer} ${t.car.model}` : "—"),
|
||||
},
|
||||
{ key: "branch", header: "الفرع", width: "1fr", render: (t) => t.branch?.name ?? "—" },
|
||||
{ key: "status", header: "الحالة", width: "1fr", render: (t) => <StatusBadge status={t.status} /> },
|
||||
{
|
||||
key: "startTime",
|
||||
header: "معاد البداء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (t) => fmtDate(t.startTime),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}>
|
||||
<span>الرحلة</span>
|
||||
<span>السائق</span>
|
||||
<span>السيارة</span>
|
||||
<span>الفرع</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>وقت البدء</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
) : trips.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="🚚"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد رحلات لعرضها."}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
أضف أول رحلة
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={trips}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
emptyIcon="🚚"
|
||||
emptyDescription={!search ? "No trips to display." : undefined}
|
||||
emptyAction={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
Add first trip
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
renderActions={(t) => (
|
||||
<ActionButtons
|
||||
itemLabel={t.title}
|
||||
onView={() => onView(t)}
|
||||
onEdit={() => onEdit(t)}
|
||||
onDelete={() => onDelete(t)}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{trips.map((t, i) => (
|
||||
<li key={t.id} 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,
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{t.tripNumber}</p>
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.driver?.name ?? "—"}</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.car ? `${t.car.manufacturer} ${t.car.model}` : "—"}</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.branch?.name ?? "—"}</span>
|
||||
<StatusBadge status={t.status} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{fmtDate(t.startTime)}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view */}
|
||||
<IconBtn title={`عرض ${t.title}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(t)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* edit */}
|
||||
<IconBtn title={`تعديل ${t.title}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(t)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* delete */}
|
||||
<IconBtn title={`حذف ${t.title}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(t)}>
|
||||
<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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/TripFormModal.tsx
|
||||
// CHANGE: renamed from Tripformmodal.tsx (proper PascalCase, matches
|
||||
// UserFormModal.tsx / DriverFormModal.tsx). Split out of the old single-file
|
||||
// Trip module into TripFormModal.tsx / TripTable.tsx / TripDetailModal.tsx.
|
||||
// FIX 1: notes/endReason schema — empty string now transforms to undefined
|
||||
// before .min() runs, so leaving them blank on create no longer fails
|
||||
// validation despite .optional() (see trip.validator.ts).
|
||||
// FIX 2: edit-mode defaultValues now fall back to the nested driver/car/branch
|
||||
// ids, and setValue() re-applies the saved id once each dropdown's options
|
||||
// actually load — previously the <select> had no matching <option> yet when
|
||||
// defaultValues were first applied, so the old selection silently reset to "".
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/archive/ArchivedTripModal.tsx
|
||||
// Split out of the old ArchivedTripList.tsx — this is the top-level modal
|
||||
// wrapper (search bar + ArchivedTripTable + opens ArchivedTripDetailModal),
|
||||
// mirroring ArchivedDrivers.tsx / ArchivedUsersModal.tsx.
|
||||
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Input, Modal } from "@/src/Components/UI";
|
||||
@@ -16,18 +13,7 @@ interface ArchivedTripModalProps {
|
||||
}
|
||||
|
||||
const searchIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,135 +1,79 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/archive/ArchivedTripTable.tsx
|
||||
// Split out of the old ArchivedTripList.tsx — this is just the table portion
|
||||
// (headers, rows, pagination), mirroring ArchivedDriverTable.tsx. Styling is
|
||||
// preserved exactly (Tailwind classes) as it was in the original file.
|
||||
|
||||
import { Spinner, EmptyState, Button } from "@/src/Components/UI";
|
||||
import { ReusableTable } from "@/src/Components/UI";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
|
||||
// ── Status badge — reuses the existing trip status color map ────────────────
|
||||
function TripStatusBadge({ status }: { status: Trip["status"] }) {
|
||||
const s = TRIP_STATUS_MAP[status];
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-bold"
|
||||
style={{ color: s.color, background: s.bg, border: `1px solid ${s.border}` }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Date formatting helper ───────────────────────────────────────────────────
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
||||
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface ArchivedTripTableProps {
|
||||
trips: Trip[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (trip: Trip) => void;
|
||||
trips: Trip[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (trip: Trip) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArchivedTripTable
|
||||
* Renders the paginated, searchable table of archived trips, including
|
||||
* loading, empty, and data-row states. Mirrors ArchivedDriverTable.tsx.
|
||||
*/
|
||||
export function ArchivedTripTable({ trips, loading, search, page, pages, onView, onPageChange }: ArchivedTripTableProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
|
||||
{/* column headers */}
|
||||
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-muted)] px-6 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
<span>الرحلة</span>
|
||||
<span>الحالة</span>
|
||||
<span className="text-center">البدء</span>
|
||||
<span className="text-center">الانتهاء</span>
|
||||
<span className="text-center">النقد المحصّل</span>
|
||||
</div>
|
||||
|
||||
{/* ── Loading state ── */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-3 py-16 text-[var(--color-text-muted)]">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-[13px]">جارٍ التحميل…</span>
|
||||
const columns: TableColumn<Trip>[] = [
|
||||
{
|
||||
key: "title",
|
||||
header: "الرحلة",
|
||||
width: "2fr",
|
||||
render: (t) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-brand-600)" }}>{t.tripNumber}</p>
|
||||
</div>
|
||||
) : trips.length === 0 ? (
|
||||
/* ── Empty state ── */
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title="لا توجد رحلات مؤرشفة"
|
||||
description={search ? `لا توجد نتائج لـ "${search}"` : "لم يتم أرشفة أي رحلات بعد."}
|
||||
/>
|
||||
) : (
|
||||
/* ── Data rows ── */
|
||||
<ul className="m-0 list-none p-0">
|
||||
{trips.map((trip, i) => (
|
||||
<li
|
||||
key={trip.id}
|
||||
onClick={() => onView(trip)}
|
||||
className={`grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] items-center gap-2 border-b border-[var(--color-border)] px-6 py-3.5 text-[13px] transition-colors hover:bg-[var(--color-surface-muted)] cursor-pointer ${
|
||||
i % 2 !== 0 ? "bg-[var(--color-surface-muted)]/40" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="m-0 truncate font-semibold text-[var(--color-text-primary)]">{trip.title}</p>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-[var(--color-brand-600)]">{trip.tripNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<TripStatusBadge status={trip.status} />
|
||||
</div>
|
||||
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
{fmtDate(trip.startTime)}
|
||||
</span>
|
||||
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
{fmtDate(trip.endTime)}
|
||||
</span>
|
||||
<span className="text-center font-mono text-[12px] font-semibold text-[var(--color-text-primary)]">
|
||||
{trip.totalCashCollected != null
|
||||
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
|
||||
: "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3.5">
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||
صفحة <strong className="text-[var(--color-text-primary)]">{page}</strong> من{" "}
|
||||
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "الحالة",
|
||||
width: "1.5fr",
|
||||
render: (t) => {
|
||||
const s = TRIP_STATUS_MAP[t.status];
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: s.color }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === pages}
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: "start", header: "تاريخ البداية", width: "1fr", align: "center", render: (t) => fmtDate(t.startTime) },
|
||||
{ key: "end", header: "تاريخ الانتهاء", width: "1fr", align: "center", render: (t) => fmtDate(t.endTime) },
|
||||
{
|
||||
key: "cash",
|
||||
header: "المبلغ المحصل",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (t) => (
|
||||
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)" }}>
|
||||
{t.totalCashCollected != null ? `${Number(t.totalCashCollected).toLocaleString("en-US")} SAR` : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={trips}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
onRowClick={onView}
|
||||
emptyIcon="🗄️"
|
||||
emptyDescription={search ? `No results for "${search}"` : "No archived trips."}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
src/Components/UI/ActionButtons.tsx
Normal file
45
src/Components/UI/ActionButtons.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
|
||||
|
||||
import { IconBtn } from "./IconBtn";
|
||||
import type { ActionButtonsProps } from "@/src/types/models";
|
||||
|
||||
|
||||
const ViewIcon = <i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />;
|
||||
|
||||
const EditIcon = <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />;
|
||||
|
||||
const DeleteIcon = <i className="ti ti-trash" style={{ fontSize: 14 }} aria-hidden="true" />;
|
||||
|
||||
export function ActionButtons({
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
itemLabel = "",
|
||||
viewLabel = "View",
|
||||
editLabel = "Edit",
|
||||
deleteLabel = "Delete",
|
||||
}: ActionButtonsProps) {
|
||||
const suffix = itemLabel ? ` ${itemLabel}` : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
{onView && (
|
||||
<IconBtn title={`${viewLabel}${suffix}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={onView}>
|
||||
{ViewIcon}
|
||||
</IconBtn>
|
||||
)}
|
||||
{onEdit && (
|
||||
<IconBtn title={`${editLabel}${suffix}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={onEdit}>
|
||||
{EditIcon}
|
||||
</IconBtn>
|
||||
)}
|
||||
{onDelete && (
|
||||
<IconBtn title={`${deleteLabel}${suffix}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={onDelete}>
|
||||
{DeleteIcon}
|
||||
</IconBtn>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -50,11 +50,7 @@ export function ArchiveButton({
|
||||
onMouseEnter={e => (e.currentTarget.style.transform = "translateY(-2px)")}
|
||||
onMouseLeave={e => (e.currentTarget.style.transform = "translateY(0)")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="4" width="20" height="5" rx="1" />
|
||||
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9" />
|
||||
<path d="M10 13h4" />
|
||||
</svg>
|
||||
<i className="ti ti-archive" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -122,17 +122,7 @@ export default function Header({
|
||||
</Select>
|
||||
) : (
|
||||
<Button onClick={onAdd}>
|
||||
<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>
|
||||
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
إضافة {name}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
57
src/Components/UI/ModelCard.tsx
Normal file
57
src/Components/UI/ModelCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// src/Components/UI/ModelCard.tsx
|
||||
//
|
||||
// Optional thin wrapper matching the "card with a title row above a table"
|
||||
// shape used e.g. around ArchivedTripList's header, OrderComponent's
|
||||
// header, and the various archive modals (count + search + table).
|
||||
// It intentionally does NOT duplicate Header.tsx (the full page-level
|
||||
// title/search/add-button bar already used on every dashboard page) —
|
||||
// it's a lighter-weight wrapper for cases where you just need a labeled
|
||||
// card around a <ReusableTable/>, e.g. inside a modal or detail panel.
|
||||
//
|
||||
// Usage:
|
||||
// <ModelCard title="Orders" count={total} countLabel="orders" action={<SearchBox />}>
|
||||
// <ReusableTable columns={columns} data={orders} ... />
|
||||
// </ModelCard>
|
||||
|
||||
import type { ModelCardProps } from "@/src/types/models";
|
||||
|
||||
export function ModelCard({ title, count, countLabel, action, children }: ModelCardProps) {
|
||||
const hasHeader = title || count !== undefined || action;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{hasHeader && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{(title || count !== undefined) && (
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
{title}
|
||||
{count !== undefined && (
|
||||
<span
|
||||
style={{
|
||||
marginInlineStart: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
({count}{countLabel ? ` ${countLabel}` : ""})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
)}
|
||||
{action && <div style={{ flexShrink: 0 }}>{action}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
src/Components/UI/ReusableTable.tsx
Normal file
273
src/Components/UI/ReusableTable.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/UI/ReusableTable.tsx
|
||||
//
|
||||
// ============================================================================
|
||||
// HOW TO USE
|
||||
// ============================================================================
|
||||
// ReusableTable replaces the hand-written <table>/<ul><li> markup that is
|
||||
// currently duplicated across UserTable.tsx, BranchTable.tsx, RoleTable.tsx,
|
||||
// TripTable.tsx, OrderTable.tsx, ArchivedDriverTable.tsx, etc. Those files
|
||||
// all share the exact same shape: a grid header row, a loading spinner, an
|
||||
// empty state, data rows built from `gridTemplateColumns`, and a two-button
|
||||
// pager — only the column definitions and cell content differ per model.
|
||||
//
|
||||
// import { ReusableTable, ActionButtons } from "@/src/Components/UI";
|
||||
// import type { TableColumn } from "@/src/types/models";
|
||||
// import type { User } from "@/src/types/user";
|
||||
//
|
||||
// const columns: TableColumn<User>[] = [
|
||||
// {
|
||||
// key: "name",
|
||||
// header: "Name",
|
||||
// width: "2fr",
|
||||
// render: (u) => (
|
||||
// <div>
|
||||
// <p style={{ fontWeight: 600 }}>{u.name}</p>
|
||||
// <p style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
// { key: "role", header: "Role", width: "1fr", render: (u) => u.role?.name ?? "—" },
|
||||
// ];
|
||||
//
|
||||
// <ReusableTable
|
||||
// columns={columns}
|
||||
// data={users}
|
||||
// loading={loading}
|
||||
// search={search}
|
||||
// page={page}
|
||||
// pages={pages}
|
||||
// onPageChange={setPage}
|
||||
// onRowClick={(u) => setViewUserId(u.id)}
|
||||
// renderActions={(u) => (
|
||||
// <ActionButtons
|
||||
// itemLabel={u.name}
|
||||
// onView={() => setViewUserId(u.id)}
|
||||
// onEdit={() => setFormTarget(u)}
|
||||
// onDelete={() => setDeleteTarget(u)}
|
||||
// />
|
||||
// )}
|
||||
// />
|
||||
//
|
||||
// This produces byte-for-byte the same visual result as the existing
|
||||
// UserTable — same cardStyle border/radius/shadow, same thStyle header
|
||||
// treatment, same zebra-striped rows, same "السابق/التالي"-style pager
|
||||
// (labels are passed as plain strings, so RTL/Arabic labels work exactly
|
||||
// as before — nothing about the language is hardcoded here).
|
||||
//
|
||||
// NOTE ON STYLING: most tables in this codebase (User/Branch/Role/Trip/
|
||||
// Order/Driver-archive) use inline `React.CSSProperties` objects with CSS
|
||||
// custom properties (`var(--color-border)`, etc). A smaller set of newer
|
||||
// files (ArchivedTripTable, ClientTable's expandable rows) use Tailwind
|
||||
// utility classes against the same tokens instead. This component follows
|
||||
// the majority inline-style pattern so it's a drop-in replacement for the
|
||||
// most common case; the two Tailwind-based tables would need a follow-up
|
||||
// pass to fully consolidate (flagged here rather than silently converted,
|
||||
// since swapping their styling approach is a separate, riskier change).
|
||||
// ============================================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Spinner } from "./Spinner";
|
||||
import type { CrudTableProps, BaseEntity } from "@/src/types/models";
|
||||
|
||||
// ── Shared styles — identical values to UserTable.tsx / BranchTable.tsx /
|
||||
// RoleTable.tsx / TripTable.tsx / OrderTable.tsx ──────────────────────────
|
||||
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)",
|
||||
};
|
||||
|
||||
export function ReusableTable<T extends BaseEntity>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
keyExtractor = (item: T) => item.id,
|
||||
onRowClick,
|
||||
search,
|
||||
emptyIcon = "📋",
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
page,
|
||||
pages,
|
||||
onPageChange,
|
||||
renderActions,
|
||||
actionsHeader = "إجراءات",
|
||||
actionsWidth = "100px",
|
||||
renderExpanded,
|
||||
}: CrudTableProps<T>) {
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
|
||||
const gridTemplateColumns = [
|
||||
...columns.map((c) => c.width ?? "1fr"),
|
||||
...(renderActions ? [actionsWidth] : []),
|
||||
].join(" ");
|
||||
|
||||
const resolvedEmptyTitle =
|
||||
emptyTitle ?? (search ? `No results for "${search}"` : "No records to display.");
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* ── Column headers ── */}
|
||||
<div style={{ display: "grid", gridTemplateColumns, ...thStyle }}>
|
||||
{columns.map((c) => (
|
||||
<span key={c.key} style={{ textAlign: c.align }}>
|
||||
{c.header}
|
||||
</span>
|
||||
))}
|
||||
{renderActions && (
|
||||
<span style={{ textAlign: "center" }}>{actionsHeader}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Loading 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 }}>Loading…</span>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
/* ── Empty state ── */
|
||||
<EmptyState
|
||||
icon={emptyIcon}
|
||||
title={resolvedEmptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
/>
|
||||
) : (
|
||||
/* ── Data rows ── */
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{data.map((item, i) => {
|
||||
const key = keyExtractor(item);
|
||||
const isExpanded = !!renderExpanded && expandedKey === key;
|
||||
const handleRowClick = renderExpanded
|
||||
? () => setExpandedKey((prev) => (prev === key ? null : key))
|
||||
: onRowClick
|
||||
? () => onRowClick(item)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<li key={key}>
|
||||
<div
|
||||
onClick={handleRowClick}
|
||||
role={renderExpanded ? "button" : undefined}
|
||||
tabIndex={renderExpanded ? 0 : undefined}
|
||||
aria-expanded={renderExpanded ? isExpanded : undefined}
|
||||
onKeyDown={
|
||||
renderExpanded
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleRowClick?.();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns,
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: isExpanded ? "none" : "1px solid var(--color-border)",
|
||||
borderInlineStart: renderExpanded
|
||||
? isExpanded
|
||||
? "3px solid var(--color-brand-600)"
|
||||
: "3px solid transparent"
|
||||
: undefined,
|
||||
background: isExpanded
|
||||
? "#EFF6FF"
|
||||
: i % 2 !== 0
|
||||
? "var(--color-surface-muted)"
|
||||
: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: handleRowClick ? "pointer" : "default",
|
||||
transition: "background 0.15s, border-color 0.15s",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<div key={c.key} style={{ textAlign: c.align }}>
|
||||
{c.render(item, i, isExpanded)}
|
||||
</div>
|
||||
))}
|
||||
{renderActions && (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", gap: 4 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{renderActions(item, i)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && renderExpanded && renderExpanded(item, i)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{pages !== undefined && pages > 1 && page !== undefined && onPageChange && (
|
||||
<div
|
||||
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)" }}>
|
||||
Page <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> of{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ export interface SpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Font size (px) for each spinner size — replaces the old h-*/w-* SVG classes.
|
||||
const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-6 w-6",
|
||||
lg: "h-10 w-10 border-4",
|
||||
sm: "16px",
|
||||
md: "24px",
|
||||
lg: "40px",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,32 +27,15 @@ const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
|
||||
*/
|
||||
export function Spinner({ size = "md", className }: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
<i
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className={cn(
|
||||
"motion-safe:animate-spin text-[var(--color-brand-600)] shrink-0",
|
||||
sizeMap[size],
|
||||
"ti ti-loader-2 motion-safe:animate-spin text-[var(--color-brand-600)] shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v8H4z"
|
||||
/>
|
||||
</svg>
|
||||
style={{ fontSize: sizeMap[size], lineHeight: 1 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,7 @@ export { Badge } from "./Badge";
|
||||
|
||||
export { ArchiveButton } from "./ArchiveButton"
|
||||
export { FileInput } from "./FailInput"
|
||||
export {IconBtn} from "./IconBtn"
|
||||
export {IconBtn} from "./IconBtn"
|
||||
export { ReusableTable } from "./ReusableTable";
|
||||
export { ActionButtons } from "./ActionButtons";
|
||||
export { ModelCard } from "./ModelCard";
|
||||
@@ -1,158 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
import { Badge, ReusableTable, ActionButtons, Button } from "@/src/Components/UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { User } from "@/src/types/user";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
|
||||
background: active ? "#DCFCE7" : "#FEF2F2",
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: active ? "#166534" : "#991B1B",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
||||
{active ? "نشط" : "معطل"}
|
||||
{active ? "Active" : "Disabled"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface UserTableProps {
|
||||
users: User[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
onView: (user: User) => void;
|
||||
onAddFirst: () => void;
|
||||
users: User[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
onView: (user: User) => void;
|
||||
onAddFirst: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: UserTableProps) {
|
||||
export function UserTable({
|
||||
users, loading, search, page, pages,
|
||||
onEdit, onDelete, onView, onAddFirst, onPageChange,
|
||||
}: UserTableProps) {
|
||||
const columns: TableColumn<User>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "الاسم",
|
||||
width: "2fr",
|
||||
render: (u) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{u.phone}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "userName",
|
||||
header: "اسم المستخدم",
|
||||
width: "1.5fr",
|
||||
render: (u) => (
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||
{u.userName ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "branch", header: "الفرع", width: "1.5fr", render: (u) => u.branch?.name ?? "—" },
|
||||
{
|
||||
key: "role",
|
||||
header: "الدور",
|
||||
width: "1fr",
|
||||
render: (u) => <Badge label={u.role?.name ?? "—"} color={u.role?.name === "System Admin" ? "indigo" : "slate"} />,
|
||||
},
|
||||
{ key: "status", header: "الحالة", width: "1fr", render: (u) => <StatusBadge active={u.isActive} /> },
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تم الانشاء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (u) => new Date(u.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
||||
<span>الاسم</span>
|
||||
<span>اسم المستخدم</span>
|
||||
<span>الفرع</span>
|
||||
<span>الدور</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
) : users.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
أضف أول مستخدم
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
emptyIcon="👥"
|
||||
emptyDescription={!search ? "No users to display." : undefined}
|
||||
emptyAction={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
Add first user
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
renderActions={(u) => (
|
||||
<ActionButtons
|
||||
itemLabel={u.name}
|
||||
onView={() => onView(u)}
|
||||
onEdit={() => onEdit(u)}
|
||||
onDelete={() => onDelete(u)}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{users.map((u, i) => (
|
||||
<li key={u.id} style={{
|
||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 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 }}>{u.name}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
|
||||
</div>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{u.branch?.name ?? "—"}</span>
|
||||
<Badge label={u.role?.name ?? "—"} color={u.role?.name === "مدير النظام" ? "indigo" : "slate"} />
|
||||
<StatusBadge active={u.isActive} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view */}
|
||||
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* edit */}
|
||||
<IconBtn title={`تعديل ${u.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(u)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* delete */}
|
||||
<IconBtn title={`حذف ${u.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(u)}>
|
||||
<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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,18 +11,7 @@ interface ArchivedUsersModalProps {
|
||||
}
|
||||
|
||||
const searchIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
);
|
||||
|
||||
export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
||||
|
||||
@@ -1,134 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
|
||||
import { ReusableTable, ActionButtons } from "../../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { ArchivedUser } from "@/src/types/user";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
||||
{active ? "نشط" : "معطل"}
|
||||
{active ? "Active" : "Disabled"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface ArchivedUserTableProps {
|
||||
users: ArchivedUser[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (user: ArchivedUser) => void;
|
||||
users: ArchivedUser[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (user: ArchivedUser) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function ArchivedUserTable({ users, loading, search, page, pages, onView, onPageChange }: ArchivedUserTableProps) {
|
||||
const columns: TableColumn<ArchivedUser>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "الاسم",
|
||||
width: "2fr",
|
||||
render: (u) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "userName",
|
||||
header: "الاسم المستخدم",
|
||||
width: "1.5fr",
|
||||
render: (u) => (
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "الحالة", width: "1fr", render: (u) => <StatusBadge active={u.isActive} /> },
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الانشاء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (u) => new Date(u.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
header: "تاريخ التعديل",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (u) => new Date(u.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
||||
<span>الاسم</span>
|
||||
<span>اسم المستخدم</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
) : users.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{users.map((u, i) => (
|
||||
<li key={u.id} style={{
|
||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 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 }}>{u.name}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
|
||||
</div>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
|
||||
<StatusBadge active={u.isActive} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(u.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view only — delete/restore not implemented yet */}
|
||||
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
emptyIcon="🗄️"
|
||||
emptyDescription={search ? `No results for "${search}"` : "No archived users."}
|
||||
renderActions={(u) => <ActionButtons itemLabel={u.name} onView={() => onView(u)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
136
src/Components/audit/AuditDetailsModel.tsx
Normal file
136
src/Components/audit/AuditDetailsModel.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
|
||||
|
||||
import { AuditLog } from "@/src/types/audit";
|
||||
import { Button, Modal } from "../UI";
|
||||
import { ActionBadge } from "./AuditTable";
|
||||
|
||||
interface AuditDetailModalProps {
|
||||
log: AuditLog;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── small helper components ───────────────────────────────────────────────
|
||||
// (Same rationale as UserDetailModal's: purpose-built layouts, not generic
|
||||
// form/action controls, so they stay custom rather than pulled from the
|
||||
// shared UI kit.)
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value?: string | null; mono?: boolean }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", gap: 4,
|
||||
padding: "0.75rem 0",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 500,
|
||||
fontFamily: mono ? "var(--font-mono)" : undefined,
|
||||
color: value ? "var(--color-text-primary)" : "var(--color-text-hint)",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{value || "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stands in for UserDetailModal's <Avatar/> — a log entry has no photo, so
|
||||
// this is a colored icon circle instead of initials.
|
||||
function ActionIcon() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
|
||||
}}>
|
||||
<i className="ti ti-clipboard-check" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataBlock({ metadata }: { metadata: Record<string, unknown> }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, padding: "0.75rem 0" }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
|
||||
بيانات إضافية
|
||||
</span>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: "0.75rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
background: "var(--color-surface-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-secondary)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
direction: "ltr",
|
||||
textAlign: "left",
|
||||
}}>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── main component ──────────────────────────────────────────────────────
|
||||
export function AuditDetailModal({ log, onClose }: AuditDetailModalProps) {
|
||||
const fmt = (iso?: string | null) =>
|
||||
iso ? new Date(iso).toLocaleString("ar-SA", { dateStyle: "long", timeStyle: "medium" }) : null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="تفاصيل السجل"
|
||||
subtitle="سجل التدقيق"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
|
||||
|
||||
{/* icon + action + timestamp row */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: "1rem",
|
||||
padding: "0 0 1.25rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
marginBottom: "0.25rem",
|
||||
}}>
|
||||
<ActionIcon />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
{log.module ?? "سجل تدقيق"}
|
||||
</p>
|
||||
<p style={{ marginTop: 3, fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
{fmt(log.createdAt)}
|
||||
</p>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ActionBadge action={log.action} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* detail rows */}
|
||||
<DetailRow label="المعرّف" value={log.entityId} mono />
|
||||
<DetailRow label="المستخدم" value={log.userName ?? log.userId} />
|
||||
<DetailRow label="عنوان IP" value={log.ipAddress} mono />
|
||||
<DetailRow label="متصفح المستخدم" value={log.userAgent} mono />
|
||||
|
||||
{log.metadata && Object.keys(log.metadata).length > 0 && (
|
||||
<MetadataBlock metadata={log.metadata} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
129
src/Components/audit/AuditTable.tsx
Normal file
129
src/Components/audit/AuditTable.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import { ReusableTable } from "../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { AuditLog } from "@/src/types/audit";
|
||||
|
||||
// ── Action badge — moved verbatim from app/dashboard/audit/page.tsx ─────────
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
// Exported (not just used locally) so AuditDetailModal can render the same
|
||||
// badge in its header instead of re-implementing the color mapping.
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString("ar-SA", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
interface AuditTableProps {
|
||||
logs: AuditLog[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onPageChange: (p: number) => void;
|
||||
onRowClick: (log: AuditLog) => void;
|
||||
}
|
||||
|
||||
export function AuditTable({
|
||||
logs, loading, search, page, pages,
|
||||
onPageChange, onRowClick,
|
||||
}: AuditTableProps) {
|
||||
const columns: TableColumn<AuditLog>[] = [
|
||||
{
|
||||
key: "action",
|
||||
header: "الإجراء",
|
||||
width: "1.5fr",
|
||||
render: (log) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{ key: "module", header: "الوحدة", width: "1fr", render: (log) => log.module ?? "—" },
|
||||
{ key: "user", header: "المستخدم", width: "1fr", render: (log) => log.userName ?? log.userId ?? "—" },
|
||||
{
|
||||
key: "ipAddress",
|
||||
header: "عنوان IP",
|
||||
width: "1.5fr",
|
||||
render: (log) => (
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
{log.ipAddress ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "الوقت",
|
||||
width: "1fr",
|
||||
render: (log) => (
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{fmtDateTime(log.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
// Now showing ALL matching logs in one request (see the page's
|
||||
// loadLogs), so instead of paging through them, the list scrolls
|
||||
// internally within a fixed max-height. `pages` will naturally stay
|
||||
// at 1 for a normal-sized result set, so ReusableTable's own pager
|
||||
// stays hidden; this wrapper is what actually gives the scroll.
|
||||
<div style={{ maxHeight: "70vh", overflowY: "auto", borderRadius: "var(--radius-xl)" }}>
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={logs}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
onRowClick={onRowClick}
|
||||
emptyDescription={search ? `لا نتائج لـ "${search}"` : "لا توجد سجلات مطابقة"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -229,11 +229,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
||||
{/* Row 1: secondary views */}
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<Button type="button" variant="secondary" style={{ flex: 1 }} onClick={() => setGallery(true)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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>
|
||||
<i className="ti ti-photo" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
الصور
|
||||
</Button>
|
||||
<Button
|
||||
@@ -242,9 +238,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
<i className="ti ti-tool" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
عرض تاريخ الصيانة
|
||||
</Button>
|
||||
</div>
|
||||
@@ -252,10 +246,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
||||
{/* Row 2: primary edit / delete */}
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<Button type="button" variant="primary" style={{ flex: 2 }} onClick={() => onEdit(car)}>
|
||||
<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>
|
||||
<Button type="button" variant="danger" onClick={() => onDelete(car)}>
|
||||
|
||||
@@ -120,9 +120,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
||||
loading={uploading}
|
||||
>
|
||||
{!uploading && (
|
||||
<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>
|
||||
<i className="ti ti-upload" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
)}
|
||||
{uploading ? "جارٍ الرفع…" : "رفع صور"}
|
||||
</Button>
|
||||
@@ -224,10 +222,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
||||
>
|
||||
{isDeleting
|
||||
? <Spinner size="sm" className="text-red-600" />
|
||||
: <svg width="12" height="12" 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" />
|
||||
</svg>
|
||||
: <i className="ti ti-trash" style={{ fontSize: 12 }} aria-hidden="true" />
|
||||
}
|
||||
</IconBtn>
|
||||
</div>
|
||||
|
||||
@@ -48,9 +48,7 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
dir="rtl"
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
|
||||
import type { Car } from "@/src/types/car";
|
||||
// src/Components/Car/archive/ArchivedCarTable.tsx
|
||||
// Converted to use ReusableTable, matching DriverTable / OrderTable /
|
||||
// AuditTable — previously this was its own hand-rolled cardStyle/thStyle
|
||||
// grid. Same columns, same view-only action (delete/restore still not
|
||||
// implemented), same empty/loading text. Row click now also opens the
|
||||
// view action, same as Driver/Order's onRowClick, in addition to the
|
||||
// explicit eye icon.
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
import { IconBtn, ReusableTable } from "../../UI";
|
||||
import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { Car } from "@/src/types/car";
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface ArchivedCarTableProps {
|
||||
@@ -29,100 +26,89 @@ interface ArchivedCarTableProps {
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function ArchivedCarTable({ cars, loading, search, page, pages, onView, onPageChange }: ArchivedCarTableProps) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
||||
<span>المركبة</span>
|
||||
<span>رقم اللوحة</span>
|
||||
<span>الفرع</span>
|
||||
<span>آخر حالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
const columns: TableColumn<Car>[] = [
|
||||
{
|
||||
key: "vehicle",
|
||||
header: "المركبة",
|
||||
width: "2fr",
|
||||
render: (c) => (
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
{c.manufacturer} {c.model}
|
||||
</p>
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{c.year}{c.color ? ` · ${c.color}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : cars.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="🚗"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{cars.map((c, i) => {
|
||||
const status = STATUS_MAP[c.currentStatus];
|
||||
return (
|
||||
<li key={c.id} style={{
|
||||
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 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 }}>
|
||||
{c.manufacturer} {c.model}
|
||||
</p>
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{c.year}{c.color ? ` · ${c.color}` : ""}</p>
|
||||
</div>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||
{c.plateLetters} {c.plateNumber}
|
||||
</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{c.branch?.name ?? "—"}</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`, background: status.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 700, color: status.color, width: "fit-content" }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} />
|
||||
{status.label}
|
||||
</span>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{fmtDateShort(c.createdAt)}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view only — delete/restore not implemented yet */}
|
||||
<IconBtn title={`عرض ${c.manufacturer} ${c.model}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(c)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "plate",
|
||||
header: "رقم اللوحة",
|
||||
width: "1.5fr",
|
||||
render: (c) => (
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||
{c.plateLetters} {c.plateNumber}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "branch", header: "الفرع", width: "1fr", render: (c) => c.branch?.name ?? "—" },
|
||||
{
|
||||
key: "status",
|
||||
header: "آخر حالة",
|
||||
width: "1fr",
|
||||
render: (c) => {
|
||||
const status = STATUS_MAP[c.currentStatus];
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${status.border}`,
|
||||
background: status.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11, fontWeight: 700, color: status.color,
|
||||
width: "fit-content",
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} />
|
||||
{status.label}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<Button
|
||||
key={btn.label}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={btn.action}
|
||||
disabled={btn.disabled}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الإضافة",
|
||||
width: "1fr",
|
||||
render: (c) => (
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{fmtDateShort(c.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={cars}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
actionsWidth="100px"
|
||||
onRowClick={onView}
|
||||
emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."}
|
||||
renderActions={(c) => (
|
||||
// view only — delete/restore not implemented yet
|
||||
<IconBtn
|
||||
title={`عرض ${c.manufacturer} ${c.model}`}
|
||||
color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
|
||||
onClick={() => onView(c)}
|
||||
>
|
||||
<i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
|
||||
</IconBtn>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Role } from "@/src/types/role";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
import { Button, ReusableTable, ActionButtons } from "../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { Role } from "@/src/types/role";
|
||||
|
||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -18,157 +16,88 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
color: active ? "#166534" : "#991B1B",
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
||||
{active ? "نشط" : "معطل"}
|
||||
{active ? "Active" : "Disabled"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── 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)",
|
||||
};
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
interface RoleTableProps {
|
||||
roles: Role[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (role: Role) => void;
|
||||
onDelete: (role: Role) => void;
|
||||
onView: (role: Role) => void;
|
||||
onAddFirst: () => void;
|
||||
roles: Role[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (role: Role) => void;
|
||||
onDelete: (role: Role) => void;
|
||||
onView: (role: Role) => void;
|
||||
onAddFirst: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── Main table ────────────────────────────────────────────────────────────────
|
||||
export function RoleTable({
|
||||
roles, loading, search, page, pages,
|
||||
onEdit, onDelete, onView, onAddFirst, onPageChange,
|
||||
}: RoleTableProps) {
|
||||
const columns: TableColumn<Role>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: " الدور",
|
||||
width: "2fr",
|
||||
render: (r) => <span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{r.name}</span>,
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "الوصف",
|
||||
width: "3fr",
|
||||
render: (r) =>
|
||||
r.description || <span style={{ color: "var(--color-text-hint)", fontStyle: "italic" }}>No description</span>,
|
||||
},
|
||||
{
|
||||
key: "permissions",
|
||||
header: "الصلحيات",
|
||||
width: "1fr",
|
||||
render: (r) => (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
width: 28, height: 28, borderRadius: "var(--radius-full)",
|
||||
background: "#EFF6FF", border: "1px solid #BFDBFE",
|
||||
fontSize: 11, fontWeight: 700, color: "#1D4ED8",
|
||||
}}>
|
||||
{r.permissions?.length ?? 0}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "الحالة", width: "1fr", render: (r) => <StatusBadge active={r.isActive} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* Column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 3fr 1fr 1fr 120px", ...thStyle }}>
|
||||
<span>اسم الدور</span>
|
||||
<span>الوصف</span>
|
||||
<span>الصلاحيات</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading ? (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
/* Empty state */
|
||||
<EmptyState
|
||||
icon="🛡️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"}
|
||||
description={!search ? "ابدأ بإنشاء أول دور في النظام" : undefined}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
إضافة أول دور
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={roles}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
actionsWidth="120px"
|
||||
emptyIcon="🛡️"
|
||||
emptyTitle={search ? undefined : "No roles to display"}
|
||||
emptyDescription={!search ? "Start by creating the first role in the system" : undefined}
|
||||
emptyAction={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
Add first role
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
renderActions={(r) => (
|
||||
<ActionButtons
|
||||
itemLabel={r.name}
|
||||
onView={() => onView(r)}
|
||||
onEdit={() => onEdit(r)}
|
||||
onDelete={() => onDelete(r)}
|
||||
/>
|
||||
) : (
|
||||
/* Data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{roles.map((role, i) => (
|
||||
<li key={role.id} style={{
|
||||
display: "grid", gridTemplateColumns: "2fr 3fr 1fr 1fr 120px",
|
||||
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,
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{role.name}</span>
|
||||
<span style={{ color: "var(--color-text-secondary)", fontSize: 12 }}>
|
||||
{role.description || <span style={{ color: "var(--color-text-hint)", fontStyle: "italic" }}>لا يوجد وصف</span>}
|
||||
</span>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
width: 28, height: 28, borderRadius: "var(--radius-full)",
|
||||
background: "#EFF6FF", border: "1px solid #BFDBFE",
|
||||
fontSize: 11, fontWeight: 700, color: "#1D4ED8",
|
||||
}}>
|
||||
{role.permissions?.length ?? 0}
|
||||
</span>
|
||||
<StatusBadge active={role.isActive} />
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* View */}
|
||||
<IconBtn title={`عرض ${role.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(role)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* Edit */}
|
||||
<IconBtn title={`تعديل ${role.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(role)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* Delete */}
|
||||
<IconBtn title={`حذف ${role.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(role)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -59,10 +59,7 @@ function RoleIcon() {
|
||||
flexShrink: 0,
|
||||
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
|
||||
}}>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
|
||||
<path d="M12 2 3 7v6c0 5 4 9 9 9s9-4 9-9V7l-9-5z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<i className="ti ti-shield-check" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,131 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import { ReusableTable, ActionButtons } from "../../UI";
|
||||
import type { TableColumn } from "@/src/types/models";
|
||||
import type { ArchivedRole } from "@/src/types/role";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
||||
{active ? "نشط" : "معطل"}
|
||||
{active ? "Active" : "Disabled"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── card / header 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)",
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface ArchivedRoleTableProps {
|
||||
roles: ArchivedRole[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (role: ArchivedRole) => void;
|
||||
roles: ArchivedRole[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (role: ArchivedRole) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function ArchivedRoleTable({ roles, loading, search, page, pages, onView, onPageChange }: ArchivedRoleTableProps) {
|
||||
const columns: TableColumn<ArchivedRole>[] = [
|
||||
{ key: "name", header: "الدور", width: "2fr", render: (r) => <span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{r.name}</span> },
|
||||
{ key: "description", header: "الوصف", width: "2fr", render: (r) => r.description || "—" },
|
||||
{ key: "status", header: "الحالة", width: "1fr", render: (r) => <StatusBadge active={r.isActive} /> },
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "تاريخ الانشاء",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (r) => new Date(r.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
header: "تاريخ التعديل",
|
||||
width: "1fr",
|
||||
align: "center",
|
||||
render: (r) => new Date(r.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 2fr 1fr 1fr 1fr 100px", ...thStyle }}>
|
||||
<span>الدور</span>
|
||||
<span>الوصف</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
|
||||
<span style={{ textAlign: "center" }}>آخر تحديث</span>
|
||||
<span style={{ textAlign: "center" }}>إجراءات</span>
|
||||
</div>
|
||||
|
||||
{/* loading 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>
|
||||
) : roles.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="🛡️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{roles.map((r, i) => (
|
||||
<li key={r.id} style={{
|
||||
display: "grid", gridTemplateColumns: "2fr 2fr 1fr 1fr 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,
|
||||
}}>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{r.name}</p>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{r.description || "—"}</span>
|
||||
<StatusBadge active={r.isActive} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(r.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(r.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view only — delete/restore not implemented yet */}
|
||||
<IconBtn title={`عرض ${r.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(r)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReusableTable
|
||||
columns={columns}
|
||||
data={roles}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onPageChange={onPageChange}
|
||||
emptyIcon="🛡️"
|
||||
emptyDescription={search ? `No results for "${search}"` : "No archived roles."}
|
||||
renderActions={(r) => <ActionButtons itemLabel={r.name} onView={() => onView(r)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,18 +11,7 @@ interface ArchivedRolesModalProps {
|
||||
}
|
||||
|
||||
const searchIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
|
||||
);
|
||||
|
||||
export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { driverService } from "@/src/services/driver.service";
|
||||
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
|
||||
@@ -59,11 +59,21 @@ export function useDrivers() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||
|
||||
// Ref so the dismiss timer can be cleared if a new notification arrives
|
||||
// before the previous one expires — prevents stale-closure memory leaks
|
||||
// and premature/duplicate toast dismissal on rapid successive calls.
|
||||
// Pattern copied from useTrip.ts for consistency across list hooks.
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const notify = useCallback((n: ToastNotification) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setNotification(n);
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
timerRef.current = setTimeout(() => setNotification(null), 4000);
|
||||
}, []);
|
||||
|
||||
// Clear pending timer on unmount
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
||||
|
||||
// ── Fetch drivers ─────────────────────────────────────────────────────────
|
||||
const loadDrivers = useCallback(async (p: number, q: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
@@ -179,7 +189,6 @@ export function useDrivers() {
|
||||
updateDriver,
|
||||
deleteDriver,
|
||||
notification,
|
||||
clearNotification: () => setNotification(null),
|
||||
reload: () => loadDrivers(page, search),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { orderService } from "@/src/services/order.service";
|
||||
import type {
|
||||
Order,
|
||||
@@ -64,11 +64,21 @@ export function useOrders() {
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||
|
||||
// Ref so the dismiss timer can be cleared if a new notification arrives
|
||||
// before the previous one expires — prevents stale-closure memory leaks
|
||||
// and premature/duplicate toast dismissal on rapid successive calls.
|
||||
// Pattern copied from useTrip.ts for consistency across list hooks.
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const notify = useCallback((n: ToastNotification) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setNotification(n);
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
timerRef.current = setTimeout(() => setNotification(null), 4000);
|
||||
}, []);
|
||||
|
||||
// Clear pending timer on unmount
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
||||
|
||||
// ── Fetch orders ───────────────────────────────────────────────────────
|
||||
const loadOrders = useCallback(async (p: number, q: string, status: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { userService } from "@/src/services/user.service";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
@@ -42,12 +42,22 @@ export function useUsers() {
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||
|
||||
// notify with auto-dismiss after 4s
|
||||
// Ref so the dismiss timer can be cleared if a new notification arrives
|
||||
// before the previous one expires — prevents stale-closure memory leaks
|
||||
// and premature/duplicate toast dismissal on rapid successive calls.
|
||||
// Pattern copied from useTrip.ts for consistency across list hooks.
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// notify with auto-dismiss after 4s
|
||||
const notify = useCallback((n: ToastNotification) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setNotification(n);
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
timerRef.current = setTimeout(() => setNotification(null), 4000);
|
||||
}, []);
|
||||
|
||||
// Clear pending timer on unmount
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
||||
|
||||
// fetch users with pagination and search
|
||||
const loadUsers = useCallback(async (p: number, q: string) => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
@@ -90,7 +100,7 @@ useEffect(() => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await userService.update(id, data, token);
|
||||
dispatch({ type: "UPDATE", user: res });
|
||||
dispatch({ type: "UPDATE", user: res?.data });
|
||||
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
14
src/types/audit.ts
Normal file
14
src/types/audit.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
|
||||
export 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;
|
||||
}
|
||||
108
src/types/models.ts
Normal file
108
src/types/models.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Minimal shape every row must satisfy so ReusableTable can key rows.
|
||||
* Extend this in your model-specific type (e.g. `interface User extends
|
||||
* BaseEntity { ... }`) — nothing here overrides your existing model types.
|
||||
*/
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a single column in ReusableTable.
|
||||
*
|
||||
* `render` receives the row item (and its index) and returns whatever
|
||||
* should appear in that cell — a plain string, a <Badge/>, a <span> with
|
||||
* custom styling, etc. This mirrors how every existing table in this
|
||||
* project (UserTable, BranchTable, RoleTable, TripTable, OrderTable...)
|
||||
* already renders its cells inline.
|
||||
*/
|
||||
export interface TableColumn<T> {
|
||||
/** Stable key for the column (used as the React key, not displayed) */
|
||||
key: string;
|
||||
/** Column header label — pass whatever language your page needs
|
||||
* (the original app's headers are Arabic, e.g. "الاسم" / "الحالة") */
|
||||
header: string;
|
||||
/** Cell renderer for a given row. `isExpanded` is only meaningful when
|
||||
* the table is used with `renderExpanded` (e.g. to rotate a chevron
|
||||
* icon) — existing 2-arg render functions keep working unchanged. */
|
||||
render: (item: T, index: number, isExpanded: boolean) => ReactNode;
|
||||
/** CSS grid track size, e.g. "2fr", "1.5fr", "120px". Defaults to "1fr" */
|
||||
width?: string;
|
||||
/** Text alignment for the column body (header stays start-aligned,
|
||||
* matching the existing tables) */
|
||||
align?: "left" | "right" | "center";
|
||||
}
|
||||
|
||||
/**
|
||||
* Props accepted by <ReusableTable/>. `T` must at minimum have an `id`
|
||||
* (see BaseEntity) so keyExtractor has a sane default.
|
||||
*/
|
||||
export interface CrudTableProps<T extends BaseEntity> {
|
||||
columns: TableColumn<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
/** Defaults to `item => item.id` — override for composite keys */
|
||||
keyExtractor?: (item: T) => string;
|
||||
/** Optional row click — e.g. open a detail panel (same pattern as
|
||||
* DriversPage / OrderComponent's row `onClick`) */
|
||||
onRowClick?: (item: T) => void;
|
||||
|
||||
/** Used only to vary the empty-state message ("no results for X") */
|
||||
search?: string;
|
||||
emptyIcon?: string;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
emptyAction?: ReactNode;
|
||||
|
||||
/** Pagination — omit `pages`/`onPageChange` to hide the pagination bar */
|
||||
page?: number;
|
||||
pages?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
|
||||
/** Renders the trailing "actions" cell for each row (typically an
|
||||
* <ActionButtons/>). Omit to render a table with no actions column. */
|
||||
renderActions?: (item: T, index: number) => ReactNode;
|
||||
actionsHeader?: string;
|
||||
actionsWidth?: string;
|
||||
|
||||
/**
|
||||
* Renders an expandable panel below a row (used by ClientTable's
|
||||
* click-to-expand detail panel). When provided, clicking a row toggles
|
||||
* that row's panel open/closed instead of firing `onRowClick`, and the
|
||||
* row gets the brand-accent highlight treatment (matches
|
||||
* ClientTable.tsx's original ClientDetailPanel behavior exactly).
|
||||
*/
|
||||
renderExpanded?: (item: T, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for <ActionButtons/>. Every handler is optional so a model that
|
||||
* only supports View + Delete (no edit, e.g. archived records) can simply
|
||||
* omit `onEdit`.
|
||||
*/
|
||||
export interface ActionButtonsProps {
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
/** Accessible label context, e.g. the row's display name — produces
|
||||
* aria-labels like "View John Doe" instead of just "View" */
|
||||
itemLabel?: string;
|
||||
viewLabel?: string;
|
||||
editLabel?: string;
|
||||
deleteLabel?: string;
|
||||
}
|
||||
|
||||
/** Props for <ModelCard/> — the card chrome wrapping a ReusableTable */
|
||||
export interface ModelCardProps {
|
||||
title?: string;
|
||||
count?: number;
|
||||
countLabel?: string;
|
||||
/** Slot for a search box / add button / filter, right-aligned like the
|
||||
* existing Header.tsx pattern */
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
Reference in New Issue
Block a user