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:
m7amedez5511
2026-07-26 20:08:12 +03:00
parent b2dcb45606
commit a1a34cb251
70 changed files with 2683 additions and 3243 deletions

View File

@@ -1,72 +1,15 @@
# Slash.sa — منصة إدارة الأسطول واللوجستيات You are a senior frontend architect specializing in admin dashboards for logistics systems. Your objective is to create a comprehensive redesign plan for the home (dashboard) page of a logistics management system built with Next.js, Tailwind CSS, and TypeScript. This system tracks shipments and orders alongside nine core entities: Users, Drivers, Cars, Trips, Orders, Customers, Branches, Roles, and Audit/Withdrawal.
منصة ويب متكاملة لإدارة عمليات الأسطول واللوجستيات، مبنية بلغة عربية أولًا (Arabic-first) The dashboard should display total counts, active counts, and pending counts for each of these entities in separate KPI cards, with visual alerts for anomalies. Additionally, the design must utilize the `@tabler/icons-webfont` icon library. It is imperative to follow the existing template block, ensuring consistency in the color palette, typography scale, spacing system, and layout patterns. The code structure must align with the existing architecture, preserving the component file structure, hooks, types folder, and page layout patterns.
باتجاه RTL كامل، وموجهة للسوق السعودي. تشمل المنصة إدارة المركبات والسائقين والفروع
والطلبات والرحلات وسجل الصيانة، مع لوحات تحكم منفصلة حسب دور المستخدم (مشرف / عميل / مستخدم ميداني).
## المتطلبات الأساسية Divide the page into specific, visually distinct sections that include: KPI cards for totals, active and pending counts; recent activity logs; alert messages for urgent issues; and charts demonstrating trends over time. Each section should be explicitly labeled. Ensure that each component in the output is accompanied by a description of its required data state, including types and default values, to facilitate direct implementation.
- Node.js 20.9 أو أحدث The output must strictly utilize only Tailwind CSS and the `@tabler/icons-webfont` library — no additional UI frameworks or libraries such as Shadcn, Material UI, or Headless UI should be included.
- npm (أو أي package manager متوافق)
## التقنيات المستخدمة The final plan should produce a structured document containing:
- A component tree presented as an indented list detailing the hierarchy and relationships.
- A wireframe description outlining the layout section by section in clear text.
- A data/state requirement table for each component that lists the necessary data attributes.
- A responsive design strategy that specifies how each section adapts to mobile, tablet, and desktop views, ensuring all elements remain accessible and legible.
| الطبقة | التقنية | Success criteria for the resulting design must be clearly defined: the dashboard must cover all nine entities, use the specified icon set exclusively, respect the existing design tokens, and create a coherent layout that minimizes navigation clicks while maximizing data visibility. The entire output must be written in clear, technical English.
| -------------------- | ------------------------------------------ |
| الإطار | Next.js 16 (App Router) |
| اللغة | TypeScript |
| التنسيق | Tailwind CSS v4 |
| النماذج والتحقق | react-hook-form + yup |
| الأيقونات | lucide-react (صفحات التسويق) / Tabler Icons Webfont (لوحة التحكم) |
| المصادقة | JWT + HttpOnly cookie + middleware للحماية |
## هيكل المشروع (مختصر)
app/
├── (صفحات تسويقية) home, features/app, how_work, prices, frequently_asked_questions
├── dashboard/ لوحة التحكم (محمية بواسطة middleware)
│ ├── cars, drivers, orders, trips, clients, branches, roles, audit, users
├── login, register صفحات المصادقة
├── api/proxy/[...path] بروكسي موحّد لكل طلبات الـ backend
└── components/layout Navbar, Sidebar, Topbar (تُستخدم عبر الصفحات يدويًا)
src/
├── Components/ مكوّنات لوحة التحكم (مقسّمة حسب الوحدة: Car, Driver, Order...)
├── hooks/ منطق جلب البيانات والطفرات (Create/Update/Delete) لكل وحدة
├── services/ طبقة استدعاء الـ API (كل وحدة لها service منفصل)
├── types/ تعريفات TypeScript لكل نموذج بيانات
├── validations/ مخططات yup للتحقق من صحة النماذج
└── lib/ دوال مساعدة (auth, session, formatters, order-status)
## التشغيل محليًا
```bash
npm install
npm run dev
```
افتح [http://localhost:3000](http://localhost:3000) في المتصفح.
## الأوامر المتاحة
| الأمر | الوصف |
| ---------------- | --------------------------------------- |
| `npm run dev` | تشغيل بيئة التطوير (Webpack) |
| `npm run build` | بناء نسخة الإنتاج |
| `npm run start` | تشغيل نسخة الإنتاج المبنية |
| `npm run lint` | فحص الكود بواسطة ESLint |
## ملاحظات النشر (Deployment)
- المنصة مبنية على Next.js App Router وتعمل على أي بيئة تدعم Node.js 20.9+ (مثل Vercel).
- كل طلبات الـ backend تمر عبر `app/api/proxy/[...path]/route.ts`، فتأكد من ضبط الـ backend base URL بشكل صحيح قبل النشر.
- التوثيق (Authentication) يعتمد على HttpOnly cookie تُضبط عبر `app/api/auth/set-cookie`، وتُمسح عبر `app/api/clear-cookie` — تأكد من تفعيل `Secure` في بيئة الإنتاج (يتم تلقائيًا حسب `NODE_ENV`).
- الحماية على مستوى الراوت تتم عبر `middleware.ts` — راجع الـ `matcher` فيه عند إضافة مسارات محمية جديدة.
## المساهمة (Contributing)
1. أنشئ فرعًا جديدًا من `main` باسم واضح للتغيير (مثال: `feature/car-maintenance-filters`).
2. اتبع نفس أسلوب الكود الموجود: RTL-first، CSS custom property tokens بدل الألوان الثابتة، وهيكلة hooks/services/types/validations لكل وحدة جديدة.
3. شغّل `npm run lint` قبل فتح Pull Request.
4. اكتب وصفًا واضحًا للتغيير وربطه بأي تذكرة (issue) ذات صلة.
## الترخيص
جميع الحقوق محفوظة © 2026 Slash.sa

View File

@@ -8,16 +8,11 @@ import Navbar from "./Navbar";
export default function ConditionalNavbar() { export default function ConditionalNavbar() {
const pathname = usePathname(); const pathname = usePathname();
// استبدلنا getStoredUser() المباشر بالـ hook عشان يبقى SSR-safe
const { user, loading } = useStoredUser(); const { user, loading } = useStoredUser();
// لحد ما نتأكد من حالة تسجيل الدخول من الـ storage، منوريش/مخفيش
// الناف بار عشان نتجنب وميض (flash) قبل الـ hydration يخلص. مفيش
// حاجة تتعرض هنا أصلاً (مفيش avatar/اسم في الناف بار العام)، فـ null
// كافية ومفيش داعي لـ Spinner/InlineLoader في المكان ده.
if (loading) return null; if (loading) return null;
// لو المستخدم مسجل دخول، أو الصفحة الحالية جوه الداشبورد، منظهرش الناف بار العام
const isDashboardRoute = pathname?.startsWith("/dashboard"); const isDashboardRoute = pathname?.startsWith("/dashboard");
if (user || isDashboardRoute) return null; if (user || isDashboardRoute) return null;

View File

@@ -288,17 +288,7 @@ export function Sidebar() {
color: "#fff", color: "#fff",
}} }}
> >
<svg <i className="ti ti-x" style={{ fontSize: 13 }} aria-hidden="true" />
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button> </button>
<SidebarContent <SidebarContent

View File

@@ -11,9 +11,6 @@ export function Topbar() {
const router = useRouter(); const router = useRouter();
const { setOpen } = useSidebarDrawer(); const { setOpen } = useSidebarDrawer();
// كان بيتقرأ بشكل sync عن طريق getStoredUser() جوه الرندر — استبدلناه بالـ hook
// عشان يبقى SSR-safe ومايعملش hydration mismatch، وكمان يعمل subscribe
// لأي تغيير في الـ storage (تسجيل دخول/خروج من تاب تاني).
const { user, loading } = useStoredUser(); const { user, loading } = useStoredUser();
async function handleLogout() { async function handleLogout() {

View File

@@ -1,25 +1,17 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI"; import { Alert } from "@/src/Components/UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { get } from "@/src/services/api"; import { get } from "@/src/services/api";
import Header from "@/src/Components/UI/Header"; import Header from "@/src/Components/UI/Header";
import { AuditTable } from "@/src/Components/audit/AuditTable";
import { AuditDetailModal } from "@/src/Components/audit/AuditDetailsModel";
import { AuditLog } from "@/src/types/audit";
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
// (AuditLog itself now lives in @/src/types/audit so AuditTable and
interface AuditLog { // AuditDetailModal can both import it — same pattern as Driver/Order.)
id: string;
action: string;
module?: string | null;
entityId?: string | null;
userId?: string | null;
userName?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
}
// ── State / Reducer ──────────────────────────────────────────────────────── // ── State / Reducer ────────────────────────────────────────────────────────
@@ -58,44 +50,6 @@ function reducer(s: State, a: Action): State {
} }
} }
// ── Action badge ───────────────────────────────────────────────────────────
const ACTION_COLORS: Record<
string,
{ bg: string; color: string; border: string }
> = {
CREATE: { bg: "#DCFCE7", color: "#166534", border: "#BBF7D0" },
UPDATE: { bg: "#EFF6FF", color: "#1D4ED8", border: "#BFDBFE" },
DELETE: { bg: "#FEF2F2", color: "#DC2626", border: "#FECACA" },
LOGIN: { bg: "#F5F3FF", color: "#5B21B6", border: "#DDD6FE" },
LOGOUT: { bg: "#F1F5F9", color: "#475569", border: "#E2E8F0" },
};
function ActionBadge({ action }: { action: string }) {
const upper = action?.toUpperCase() ?? "";
const key =
Object.keys(ACTION_COLORS).find((k) => upper.includes(k)) ?? "CREATE";
const cfg = ACTION_COLORS[key];
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
borderRadius: "var(--radius-full)",
border: `1px solid ${cfg.border}`,
background: cfg.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 700,
color: cfg.color,
whiteSpace: "nowrap",
}}
>
{action}
</span>
);
}
// ── Page ─────────────────────────────────────────────────────────────────── // ── Page ───────────────────────────────────────────────────────────────────
export default function AuditPage() { export default function AuditPage() {
@@ -109,12 +63,18 @@ export default function AuditPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [module, setModule] = useState(""); const [module, setModule] = useState("");
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const loadLogs = useCallback(async (p: number, q: string, mod: string) => { const loadLogs = useCallback(async (q: string, mod: string) => {
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });
try { try {
const token = getStoredToken(); const token = getStoredToken();
const params = new URLSearchParams({ page: String(p), limit: "15" }); // Show ALL matching logs at once instead of paginating — a high
// limit stands in for "no limit" against an endpoint that still
// expects page/limit params. If the backend enforces its own hard
// cap below this number, ask it to expose an explicit "all" mode
// instead of relying on this number being high enough.
const params = new URLSearchParams({ page: "1", limit: "1000" });
if (q) params.set("search", q); if (q) params.set("search", q);
if (mod) params.set("module", mod); if (mod) params.set("module", mod);
const res = await get<{ const res = await get<{
@@ -148,32 +108,14 @@ export default function AuditPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
loadLogs(page, search, module); loadLogs(search, module);
}, [page, search, module, loadLogs]); }, [search, module, loadLogs]);
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
return ( return (
<section <>
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} <section
> style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
{/* Header */} {/* Header */}
<Header <Header
state={state} state={state}
@@ -197,168 +139,23 @@ export default function AuditPage() {
)} )}
{/* Table */} {/* Table */}
<div style={cardStyle}> <AuditTable
<div logs={state.logs}
dir="rtl" loading={state.loading}
style={{ search={search}
display: "grid", page={page}
gridTemplateColumns: "1.5fr 1fr 1fr 1.5fr 1fr", pages={state.pages}
...thStyle, onPageChange={setPage}
}} onRowClick={(log) => setSelectedLog(log)}
> />
<span>الإجراء</span>
<span>الوحدة</span>
<span>المستخدم</span>
<span>عنوان IP</span>
<span style={{ textAlign: "center" }}>الوقت</span>
</div>
{state.loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "4rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : state.logs.length === 0 ? (
<p
style={{
textAlign: "center",
padding: "4rem 0",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
لا توجد سجلات مطابقة
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{state.logs.map((log, i) => (
<li
key={log.id}
style={{
display: "grid",
gridTemplateColumns: "1.5fr 1fr 1fr 1.5fr 1fr",
alignItems: "center",
gap: "0.5rem",
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background:
i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}
>
<div>
<ActionBadge action={log.action} />
{log.entityId && (
<p
style={{
marginTop: 2,
fontFamily: "var(--font-mono)",
fontSize: 10,
color: "var(--color-text-muted)",
}}
>
{log.entityId.slice(0, 12)}
</p>
)}
</div>
<span style={{ color: "var(--color-text-secondary)" }}>
{log.module ?? "—"}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{log.userName ?? log.userId ?? "—"}
</span>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--color-text-muted)",
}}
>
{log.ipAddress ?? "—"}
</span>
<span
style={{
textAlign: "center",
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{new Date(log.createdAt).toLocaleString("ar-SA", {
dateStyle: "short",
timeStyle: "short",
})}
</span>
</li>
))}
</ul>
)}
{state.pages > 1 && (
<div
dir="rtl"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid var(--color-border)",
padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{page}
</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{state.pages}
</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{
label: "السابق",
action: () => setPage((p) => Math.max(1, p - 1)),
disabled: page === 1,
},
{
label: "التالي",
action: () => setPage((p) => Math.min(state.pages, p + 1)),
disabled: page === state.pages,
},
].map((btn) => (
<button
key={btn.label}
onClick={btn.action}
disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12,
color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}
>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section> </section>
{selectedLog && (
<AuditDetailModal
log={selectedLog}
onClose={() => setSelectedLog(null)}
/>
)}
</>
); );
} }

View File

@@ -73,24 +73,21 @@ function MaintenanceCard({
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}> <div style={{ display: "flex", justifyContent: "space-between", marginTop: "1rem" }}>
<Button variant="secondary" size="sm" fullWidth onClick={onView}> <Button variant="secondary" size="sm" onClick={onEdit}>
إظهار التفاصيل تعديل
</Button> </Button>
<Button variant="secondary" size="sm" onClick={onEdit}> {/* inline style keeps the original light-red look; Button's own
تعديل variant="danger" is solid red and would change the visual */}
</Button> <Button
{/* inline style keeps the original light-red look; Button's own variant="secondary"
variant="danger" is solid red and would change the visual */} size="sm"
<Button onClick={onDelete}
variant="secondary" style={{ background: "#FEF2F2", borderColor: "#FECACA", color: "#DC2626" }}
size="sm" >
onClick={onDelete} أرشفة
style={{ background: "#FEF2F2", borderColor: "#FECACA", color: "#DC2626" }} </Button>
> </div>
أرشفة
</Button>
</div>
</div> </div>
</article> </article>
); );
@@ -163,9 +160,7 @@ export default function CarMaintenancePage() {
backLabel="المركبات" backLabel="المركبات"
action={ action={
<Button onClick={() => setFormTarget(null)}> <Button onClick={() => setFormTarget(null)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة سجل صيانة إضافة سجل صيانة
</Button> </Button>
} }

View File

@@ -166,9 +166,7 @@ export default function CarMaintenancePage() {
display: "inline-flex", alignItems: "center", gap: 7, display: "inline-flex", alignItems: "center", gap: 7,
fontFamily: "var(--font-sans)", whiteSpace: "nowrap", fontFamily: "var(--font-sans)", whiteSpace: "nowrap",
}}> }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة سجل صيانة إضافة سجل صيانة
</button> </button>
} }

View File

@@ -1,18 +1,50 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Spinner, Alert, ArchiveButton, ConfirmDialog, Toast, EmptyState, Button } from "@/src/Components/UI"; import { Spinner, Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
import { CarFormModal,CarDetailPanel } from "@/src/Components/car"; import { CarFormModal,CarDetailPanel } from "@/src/Components/car";
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal"; import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal"; import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars"; import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance"; import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car"; import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
import type { Car, CreateCarPayload, UpdateCarPayload } from "@/src/types/car"; import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance"; import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance";
import Header from "@/src/Components/UI/Header";
// ── Toast ─────────────────────────────────────────────────────────────────────
function CarToast({ notification }: { notification: ToastMsg | null }) {
if (!notification) return null;
const ok = notification.type === "success";
return (
<div role="status" aria-live="polite" style={{
position: "fixed", bottom: 24, left: "50%",
transform: "translateX(-50%)",
zIndex: 9999, pointerEvents: "none",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: ok ? "#065F46" : "#7F1D1D",
color: "#FFF", fontSize: 13, fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,.25)",
maxWidth: "90vw", whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}>
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
<span>{notification.message}</span>
</div>
</div>
);
}
// ── CarCard ─────────────────────────────────────────────────────────────────── // ── CarCard ───────────────────────────────────────────────────────────────────
// NOTE: hover/lift affordance is implemented via Tailwind `hover:`/`focus-visible:`
// classes instead of onMouseEnter/onMouseLeave DOM mutation, so keyboard users
// (tabbing to this card, which is already role="button" tabIndex={0}) get the
// same visual feedback as mouse users via the native :focus-visible pseudo-class.
function CarCard({ function CarCard({
car, car,
@@ -45,23 +77,7 @@ function CarCard({
tabIndex={0} tabIndex={0}
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onClick(); }} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onClick(); }}
aria-label={`${car.manufacturer} ${car.model}${car.plateLetters} ${car.plateNumber}`} aria-label={`${car.manufacturer} ${car.model}${car.plateLetters} ${car.plateNumber}`}
style={{ className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden shadow-[var(--shadow-card)] cursor-pointer outline-none transition-all duration-200 hover:shadow-[0_8px_24px_rgba(37,99,235,.12)] hover:-translate-y-0.5 focus-visible:shadow-[0_8px_24px_rgba(37,99,235,.12)] focus-visible:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-600)] focus-visible:ring-offset-2"
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
cursor: "pointer",
transition: "box-shadow 200ms, transform 200ms",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.boxShadow = "0 8px 24px rgba(37,99,235,.12)";
(e.currentTarget as HTMLElement).style.transform = "translateY(-2px)";
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.boxShadow = "var(--shadow-card)";
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
}}
> >
{/* Colour accent bar by status */} {/* Colour accent bar by status */}
<div style={{ <div style={{
@@ -143,11 +159,7 @@ function CarCard({
</div> </div>
</div> </div>
{/* Send to Maintenance quick action — kept custom (amber accent isn't {/* Send to Maintenance quick action */}
one of Button's variants: primary/secondary/ghost/danger). Same
rationale as IconBtn: forcing it into an existing variant would
misrepresent this as a routine action rather than the
maintenance-triggering one it is. */}
<button <button
type="button" type="button"
title={maintenanceDisabledReason} title={maintenanceDisabledReason}
@@ -170,9 +182,7 @@ function CarCard({
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-tool" style={{ fontSize: 13 }} aria-hidden="true" />
<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>
إرسال للصيانة إرسال للصيانة
</button> </button>
@@ -235,7 +245,7 @@ export default function CarsPage() {
// ── Render ────────────────────────────────────────────────────────────────── // ── Render ──────────────────────────────────────────────────────────────────
return ( return (
<> <>
<Toast notification={toast} /> <CarToast notification={toast} />
{detailId && ( {detailId && (
<CarDetailPanel <CarDetailPanel
@@ -284,7 +294,75 @@ export default function CarsPage() {
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl"> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl">
{/* ── Header ── */} {/* ── Header ── */}
<Header state={{ total }} search={search} setSearch={setSearch} module="" setModule={() => {}} setPage={setPage} title="المركبات" mainTitle="إدارة المركبات" name="مركبة" isAudit={false} onAdd={() => setFormTarget(null)}/> <header style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الأسطول
</p>
<div style={{ marginTop: "0.75rem", display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-end", justifyContent: "space-between" }}>
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
المركبات
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> مركبة في الأسطول
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
{/* Search */}
<div style={{ position: "relative", width: 256 }}>
<i
className="ti ti-search"
aria-hidden="true"
style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
/>
<input
type="text"
placeholder="بحث بالماركة أو اللوحة..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
dir="rtl"
style={{
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
}}
/>
</div>
{/* Add button */}
<button
type="button"
onClick={() => setFormTarget(null)}
style={{
height: 40, padding: "0 1.125rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 7,
fontFamily: "var(--font-sans)",
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
whiteSpace: "nowrap",
}}
>
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
إضافة مركبة
</button>
</div>
</div>
</header>
{/* Error alert */} {/* Error alert */}
{error && <Alert type="error" message={error} onClose={() => setError(null)} />} {error && <Alert type="error" message={error} onClose={() => setError(null)} />}
@@ -295,16 +373,36 @@ export default function CarsPage() {
<span style={{ fontSize: 14 }}>جارٍ تحميل المركبات</span> <span style={{ fontSize: 14 }}>جارٍ تحميل المركبات</span>
</div> </div>
) : cars.length === 0 ? ( ) : cars.length === 0 ? (
<EmptyState <div style={{
icon="🚗" borderRadius: "var(--radius-xl)",
title={search ? `لا توجد مركبات تطابق "${search}"` : "لا توجد مركبات بعد"} border: "2px dashed var(--color-border)",
description='اضغط على "إضافة مركبة" لإضافة أول مركبة في الأسطول.' background: "var(--color-surface)",
action={!search && ( padding: "5rem 2rem",
<Button variant="primary" onClick={() => setFormTarget(null)}> textAlign: "center",
}}>
<div style={{ fontSize: 52, marginBottom: 16 }}>🚗</div>
<p style={{ fontSize: 16, fontWeight: 600, color: "var(--color-text-primary)" }}>
{search ? `لا توجد مركبات تطابق "${search}"` : "لا توجد مركبات بعد"}
</p>
<p style={{ fontSize: 13, color: "var(--color-text-muted)", marginTop: 6 }}>
اضغط على &quot;إضافة مركبة&quot; لإضافة أول مركبة في الأسطول.
</p>
{!search && (
<button
type="button"
onClick={() => setFormTarget(null)}
style={{
marginTop: 16, height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إضافة مركبة إضافة مركبة
</Button> </button>
)} )}
/> </div>
) : ( ) : (
/* ── Card grid ── */ /* ── Card grid ── */
<div style={{ <div style={{
@@ -312,6 +410,14 @@ export default function CarsPage() {
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: "1rem", gap: "1rem",
}}> }}>
{/* PERF NOTE: Cards are rendered inline via .map() below. At the
current pagination size (10-12 items) this is not a bottleneck.
If page size increases (e.g. "show all" mode, a larger
page-size selector, or removal of server-side pagination),
extract <CarCard> is already its own component above — wrap
it in React.memo at that point, and make sure onClick /
onSendToMaintenance passed to it are stable (useCallback)
so memoization is actually effective. */}
{cars.map((car) => ( {cars.map((car) => (
<CarCard <CarCard
key={car.id} key={car.id}
@@ -344,15 +450,24 @@ export default function CarsPage() {
{ label: "← السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 }, { label: "← السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي →", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages }, { label: "التالي →", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
].map((btn) => ( ].map((btn) => (
<Button <button
key={btn.label} key={btn.label}
variant="secondary"
size="sm"
onClick={btn.action} onClick={btn.action}
disabled={btn.disabled} 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)",
fontWeight: 600,
}}
> >
{btn.label} {btn.label}
</Button> </button>
))} ))}
</div> </div>
</div> </div>

View File

@@ -421,9 +421,7 @@ export default function ClientAddressesPage() {
onClick={() => router.push("/dashboard/clients")} onClick={() => router.push("/dashboard/clients")}
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-chevron-left" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M15 18l-6-6 6-6" />
</svg>
جميع العملاء جميع العملاء
</Button> </Button>
@@ -447,20 +445,14 @@ export default function ClientAddressesPage() {
{/* CHANGE: custom <button> → Button variant="secondary" */} {/* CHANGE: custom <button> → Button variant="secondary" */}
{client && ( {client && (
<Button type="button" variant="secondary" onClick={() => setEditingClient(true)}> <Button type="button" variant="secondary" onClick={() => setEditingClient(true)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-edit" style={{ fontSize: 13 }} aria-hidden="true" />
<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>
)} )}
{/* CHANGE: custom <button> → Button variant="primary" */} {/* CHANGE: custom <button> → Button variant="primary" */}
<Button type="button" variant="primary" onClick={() => setAddrFormTarget(null)}> <Button type="button" variant="primary" onClick={() => setAddrFormTarget(null)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة عنوان إضافة عنوان
</Button> </Button>
</div> </div>

View File

@@ -182,9 +182,7 @@ export default function DriverDetailPage() {
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)", background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
}}> }}>
<Button variant="ghost" size="sm" onClick={() => router.back()} className="mb-4 !px-0"> <Button variant="ghost" size="sm" onClick={() => router.back()} className="mb-4 !px-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-arrow-left" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
العودة إلى قائمة السائقين العودة إلى قائمة السائقين
</Button> </Button>
@@ -231,10 +229,7 @@ export default function DriverDetailPage() {
حذف السائق حذف السائق
</Button> </Button>
<Button variant="primary" onClick={() => setEditOpen(true)}> <Button variant="primary" onClick={() => setEditOpen(true)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<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>
</div> </div>

View File

@@ -1,96 +1,32 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { import { Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
Alert,
Spinner,
ArchiveButton,
ConfirmDialog,
Toast,
Button,
IconBtn,
} from "@/src/Components/UI";
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers"; import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
import { DriverTable } from "@/src/Components/Driver/DriverTable";
import { useDrivers } from "@/src/hooks/useDriver"; import { useDrivers } from "@/src/hooks/useDriver";
import { import { CreateDriverPayload, Driver, UpdateDriverPayload } from "@/src/types/driver";
CreateDriverPayload, import { DriverDetailPanel, DriverFormModal , } from "@/src/Components/Driver";
Driver,
DRIVER_STATUS_MAP,
UpdateDriverPayload,
} from "@/src/types/driver";
import { DriverDetailPanel, DriverFormModal } from "@/src/Components/Driver";
import Header from "@/src/Components/UI/Header";
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function expirySoon(iso?: string | null): boolean {
if (!iso) return false;
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
}
// ── Styles ────────────────────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.2em",
color: "var(--color-text-muted)",
background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// Shared across header + rows — keep these in sync or columns will misalign.
const ROW_GRID_COLUMNS = "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr 0.8fr";
// ── Page Component ──────────────────────────────────────────────────────────── // ── Page Component ────────────────────────────────────────────────────────────
export default function DriversPage() { export default function DriversPage() {
const { const {
drivers, drivers, loading, error, total, pages, page,
loading, search, setPage, handleSearch, clearError,
error, createDriver, updateDriver, deleteDriver,
total,
pages,
page,
search,
setPage,
handleSearch,
clearError,
createDriver,
updateDriver,
deleteDriver,
notification, notification,
clearNotification,
} = useDrivers(); } = useDrivers();
// ── Panel / modal state ─────────────────────────────────────────────────── // ── Panel / modal state ───────────────────────────────────────────────────
const [selectedDriverId, setSelectedDriverId] = useState<string | null>(null); const [selectedDriverId, setSelectedDriverId] = useState<string | null>(null);
const [formDriver, setFormDriver] = useState<Driver | null | "new">(null); const [formDriver, setFormDriver] = useState<Driver | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null); const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// Bumped after a successful edit to force the detail panel to re-fetch // Bumped after a successful edit to force the detail panel to re-fetch
const [panelRefreshKey, setPanelRefreshKey] = useState(0); const [panelRefreshKey, setPanelRefreshKey] = useState(0);
// Archive browser modal open/closed // Archive browser modal open/closed
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
// ── Handlers ────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────
const handleEdit = useCallback((driver: Driver) => { const handleEdit = useCallback((driver: Driver) => {
@@ -148,290 +84,101 @@ export default function DriversPage() {
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<> <>
<section <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
{/* ── Header ── */} {/* ── Header ── */}
<Header <header style={{
mainTitle="إدارة السائقين" borderRadius: "var(--radius-xl)",
title="السائقون" border: "1px solid var(--color-border)",
name="سائق" background: "var(--color-surface)",
state={{ total }} padding: "1.5rem 2rem",
search={search} boxShadow: "var(--shadow-card)",
setSearch={handleSearch} }}>
module="" <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
setModule={() => {}} إدارة الكوادر
setPage={setPage} </p>
isAudit={false} <div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
onAdd={() => setFormDriver("new")} <div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
/> <div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
السائقون
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "}
سائق مسجل
</p>
</div>
{/* ── Notifications ── */} <div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
<Toast notification={notification} onDismiss={clearNotification} /> {/* Search */}
{error && <Alert type="error" message={error} onClose={clearError} />} <div style={{ position: "relative", width: 288 }}>
<i
{/* ── Table ── */} className="ti ti-search"
<div style={cardStyle}> aria-hidden="true"
<div style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
dir="rtl" />
style={{ <input
display: "grid", type="text"
gridTemplateColumns: ROW_GRID_COLUMNS, placeholder="بحث بالاسم أو الهاتف..."
...thStyle, value={search}
}} onChange={(e) => handleSearch(e.target.value)}
> dir="rtl"
<span>السائق</span>
<span>الفرع</span>
<span>الجنسية</span>
<span>انتهاء الرخصة</span>
<span>انتهاء الهوية</span>
<span>الحالة</span>
<span>إجراءات</span>
</div>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "4rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : drivers.length === 0 ? (
<p
style={{
textAlign: "center",
padding: "4rem 0",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
لا توجد نتائج {search && `لـ "${search}"`}
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{drivers.map((d, i) => {
const statusCfg =
DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
const licWarn = expirySoon(d.licenseExpiry);
const idWarn = expirySoon(
(d as Driver & { nationalIdExpiry?: string })
.nationalIdExpiry,
);
return (
<li
key={d.id}
onClick={() => setSelectedDriverId(d.id)}
style={{ style={{
display: "grid", width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
gridTemplateColumns: ROW_GRID_COLUMNS, borderRadius: "var(--radius-lg)",
alignItems: "center", border: "1px solid var(--color-border)",
gap: "0.5rem", background: "var(--color-surface)",
padding: "0.875rem 1.5rem", fontSize: 13, color: "var(--color-text-primary)",
borderBottom: "1px solid var(--color-border)", outline: "none", fontFamily: "var(--font-sans)",
background:
i % 2 !== 0
? "var(--color-surface-muted)"
: "transparent",
fontSize: 13,
cursor: "pointer",
transition: "background 0.15s",
}} }}
onMouseEnter={(e) => />
(e.currentTarget.style.background = </div>
"var(--color-surface-hover, #F8FAFC)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background =
i % 2 !== 0
? "var(--color-surface-muted)"
: "transparent")
}
>
<div>
<p
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{d.name}
</p>
<p
style={{
marginTop: 2,
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{d.phone}
</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>
{d.branch?.name ?? "—"}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{d.nationality ?? "—"}
</span>
<span
style={{
fontSize: 12,
fontWeight: licWarn ? 600 : 400,
color: licWarn
? "#D97706"
: "var(--color-text-secondary)",
}}
>
{licWarn && "⚠ "}
{fmtDate(d.licenseExpiry)}
</span>
<span
style={{
fontSize: 12,
fontWeight: idWarn ? 600 : 400,
color: idWarn
? "#D97706"
: "var(--color-text-secondary)",
}}
>
{idWarn && "⚠ "}
{fmtDate(
(d as Driver & { nationalIdExpiry?: string })
.nationalIdExpiry,
)}
</span>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
borderRadius: "var(--radius-full)",
border: `1px solid ${statusCfg.border}`,
background: statusCfg.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 600,
color: statusCfg.color,
width: "fit-content",
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: statusCfg.dot,
flexShrink: 0,
}}
/>
{statusCfg.label}
</span>
{/* ── Inline row actions: edit / delete ── {/* Add button */}
IconBtn already calls stopPropagation internally so <button
the click doesn't bubble up to the <li> onClick and type="button"
open the detail panel as well. */} onClick={() => setFormDriver("new")}
<div style={{ display: "flex", gap: "0.4rem" }}> style={{
<IconBtn height: 40, padding: "0 1.25rem",
title="تعديل" borderRadius: "var(--radius-lg)",
color="var(--color-brand-600)" border: "none",
bg="var(--color-brand-50, #EFF6FF)" background: "var(--color-brand-600)",
borderColor="var(--color-brand-200)" fontSize: 13, fontWeight: 700, color: "#FFF",
onClick={() => handleEdit(d)} cursor: "pointer",
> display: "flex", alignItems: "center", gap: 8,
<svg fontFamily: "var(--font-sans)",
width="14" flexShrink: 0,
height="14" }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</IconBtn>
<IconBtn
title="حذف"
color="#DC2626"
bg="#FEF2F2"
borderColor="#FECACA"
onClick={() => handleDelete(d)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</IconBtn>
</div>
</li>
);
})}
</ul>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div
dir="rtl"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid var(--color-border)",
padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{page}
</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{pages}
</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
> >
السابق <i className="ti ti-plus" style={{ fontSize: 15 }} aria-hidden="true" />
</Button> إضافة سائق
<Button </button>
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.min(pages, p + 1))}
disabled={page === pages}
>
التالي
</Button>
</div> </div>
</div> </div>
)} </div>
</div> </header>
{/* ── Notifications ── */}
{notification && (
<Alert type={notification.type} message={notification.message} />
)}
{error && (
<Alert type="error" message={error} onClose={clearError} />
)}
{/* ── Table ── */}
<DriverTable
drivers={drivers}
loading={loading}
search={search}
page={page}
pages={pages}
onPageChange={setPage}
onRowClick={(d) => setSelectedDriverId(d.id)}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</section> </section>
{/* ── Detail panel — key forces re-fetch after successful edit ── */} {/* ── Detail panel — key forces re-fetch after successful edit ── */}
@@ -466,7 +213,9 @@ export default function DriversPage() {
/> />
{/* ── Archive browser modal ── */} {/* ── Archive browser modal ── */}
{archiveOpen && <ArchivedDrivers onClose={() => setArchiveOpen(false)} />} {archiveOpen && (
<ArchivedDrivers onClose={() => setArchiveOpen(false)} />
)}
{/* ── Floating button to open the archive browser ── */} {/* ── Floating button to open the archive browser ── */}
<ArchiveButton onClick={() => setArchiveOpen(true)} /> <ArchiveButton onClick={() => setArchiveOpen(true)} />

View File

@@ -1,18 +1,17 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI"; import { Alert, Toast, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
import { ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailModel";
import { OrderTable } from "@/src/Components/Order/OrderTable";
import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal"; import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal";
import { useOrders } from "@/src/hooks/useOrder"; import { useOrders } from "@/src/hooks/useOrder";
import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order"; import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order";
import { OrderFormModal, OrderDetailPanel, OrderTable } from "@/src/Components/Order"; import { OrderFormModal , OrderDetailPanel} from "@/src/Components/Order";
import Header from "@/src/Components/UI/Header";
// ── Page Component ─────────────────────────────────────────────────────── // ── Page Component ───────────────────────────────────────────────────────
// Table markup/styles now live in src/Components/Order/orderTable.tsx;
// detail panel in OrderDetailModel.tsx (renamed from OrderDetailBanel.tsx);
// form modal unchanged in OrderFormModal.tsx. This page only wires state
// and handlers between the three.
export default function OrderComponent() { export default function OrderComponent() {
const { const {
@@ -93,18 +92,97 @@ export default function OrderComponent() {
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Header ── */} {/* ── Header ── */}
<Header mainTitle="إدارة الطلبات" <header style={{
title="الطلبات" borderRadius: "var(--radius-xl)",
name="طلب" border: "1px solid var(--color-border)",
state={{ total }} background: "var(--color-surface)",
search={search} padding: "1.5rem 2rem",
setSearch={handleSearch} boxShadow: "var(--shadow-card)",
module="" }}>
setModule={() => {}} <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
setPage={setPage} إدارة الشحنات
isAudit={false} </p>
onAdd={() => setFormOrder("new")}/> <div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
الطلبات
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "}
طلب مسجل
</p>
</div>
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
{/* Status filter — ported from app/dashboard/orders/page.tsx */}
<select
value={statusFilter}
onChange={(e) => handleStatusFilter(e.target.value)}
dir="rtl"
style={{
height: 40, borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-secondary)",
padding: "0 0.75rem", outline: "none",
fontFamily: "var(--font-sans)", cursor: "pointer",
}}
>
<option value="">كل الحالات</option>
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
{/* Search */}
<div style={{ position: "relative", width: 288 }}>
<i
className="ti ti-search"
aria-hidden="true"
style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
/>
<input
type="text"
placeholder="رقم الشحنة أو المستلم..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
dir="rtl"
style={{
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
}}
/>
</div>
{/* Add button */}
<button
type="button"
onClick={() => setFormOrder("new")}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
flexShrink: 0,
}}
>
<i className="ti ti-plus" style={{ fontSize: 15 }} aria-hidden="true" />
إضافة طلب
</button>
</div>
</div>
</div>
</header>
{/* ── Notifications: page-level error banner (e.g. failed list load) {/* ── Notifications: page-level error banner (e.g. failed list load)
dismissible via clearError() from the hook. Action-result dismissible via clearError() from the hook. Action-result
@@ -124,7 +202,7 @@ export default function OrderComponent() {
page={page} page={page}
pages={pages} pages={pages}
setPage={setPage} setPage={setPage}
onRowClick={setSelectedOrderId} onRowClick={(orderId) => setSelectedOrderId(orderId)}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
/> />
@@ -183,5 +261,6 @@ export default function OrderComponent() {
} }
// Named export alongside the default, mirroring src/Components/Driver/index.ts's // Named export alongside the default, mirroring src/Components/Driver/index.ts's
// pattern of re-exporting each piece for use elsewhere. // pattern of re-exporting each piece for use elsewhere (e.g. a future
// app/dashboard/orders/page.tsx importing { OrderComponent }).
export { OrderComponent }; export { OrderComponent };

View File

@@ -1,13 +1,6 @@
"use client"; "use client";
// app/dashboard/trips/[tripId]/page.tsx
// CHANGE: TripFormModal now imported from the barrel (@/src/Components/Trip)
// instead of a direct path — avoids the Tripformmodal.tsx case-sensitivity
// mismatch that breaks on case-sensitive filesystems (Linux/Docker builds).
// CHANGE: loadTrip's catch block now uses the same extractApiMessage helper
// already defined in this file (and used by handleEditSubmit/handleConfirmDelete)
// instead of `err instanceof Error ? err.message : ...` — so backend
// validation messages surface correctly here too, not just on edit/delete.
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
@@ -336,9 +329,7 @@ export default function TripDetailPage() {
onClick={() => router.back()} onClick={() => router.back()}
style={{ height: "auto", padding: 0, background: "none", marginBottom: "1rem" }} style={{ height: "auto", padding: 0, background: "none", marginBottom: "1rem" }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-arrow-left" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
العودة إلى قائمة الرحلات العودة إلى قائمة الرحلات
</Button> </Button>
@@ -437,10 +428,7 @@ export default function TripDetailPage() {
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<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>
</div> </div>

View File

@@ -1,16 +1,7 @@
"use client"; "use client";
import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal"; import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal";
// app/dashboard/trips/archived/[tripId]/page.tsx
// CHANGE: rebuilt as a thin route wrapper around the shared
// ArchivedTripDetailModal instead of its own independent fetch/loading/error/
// render logic. This route now shows the exact same full detail view as the
// in-app archive browser (driver + car + counts + cash + notes + endReason,
// not just the handful of fields this page used to render on its own), and
// stays in sync automatically with any future changes to that component.
// `onClose` navigates back to the trips list (via router.push) instead of
// closing an in-place modal, since this route has no page behind it to
// reveal.
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";

View File

@@ -21,11 +21,7 @@ export default function ForbiddenPage() {
border: "2px solid #FECACA", border: "2px solid #FECACA",
display: "flex", alignItems: "center", justifyContent: "center", display: "flex", alignItems: "center", justifyContent: "center",
}}> }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="1.5"> <i className="ti ti-shield-exclamation" style={{ fontSize: 36, color: "#DC2626" }} aria-hidden="true" />
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div> </div>
<p style={{ <p style={{

View File

@@ -55,10 +55,7 @@ export default function NotFound() {
textDecoration: "none", textDecoration: "none",
display: "inline-flex", alignItems: "center", gap: 8, display: "inline-flex", alignItems: "center", gap: 8,
}}> }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-home" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
لوحة التحكم لوحة التحكم
</Link> </Link>

View File

@@ -39,10 +39,7 @@ function BranchIcon() {
flexShrink: 0, flexShrink: 0,
boxShadow: "0 4px 12px rgba(37,99,235,.3)", 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"> <i className="ti ti-building" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
<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>
</div> </div>
); );
} }

View File

@@ -1,134 +1,96 @@
"use client"; "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"; 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 { interface BranchTableProps {
branches: Branch[]; branches: Branch[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onEdit: (branch: Branch) => void; onEdit: (branch: Branch) => void;
onDelete: (branch: Branch) => void; onDelete: (branch: Branch) => void;
onView: (branch: Branch) => void; onView: (branch: Branch) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ─────────────────────────────────────────────────────────────── export function BranchTable({
export function BranchTable({ branches, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: BranchTableProps) { 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px", ...thStyle }}> data={branches}
<span>الفرع</span> loading={loading}
<span>المدينة</span> search={search}
<span>الهاتف</span> page={page}
<span>الحالة</span> pages={pages}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyIcon="🏢"
</div> emptyDescription={!search ? "No branches to display." : undefined}
emptyAction={
{/* loading state */} !search && (
{loading ? ( <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> Add first branch
<Spinner size="sm" className="text-blue-600" /> </Button>
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> )
</div> }
) : branches.length === 0 ? ( renderActions={(b) => (
/* empty state */ <ActionButtons
<EmptyState itemLabel={b.name}
icon="🏢" onView={() => onView(b)}
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."} onEdit={() => onEdit(b)}
action={ onDelete={() => onDelete(b)}
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
أضف أول فرع
</Button>
)
}
/> />
) : (
/* 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>
); );
} }

View File

@@ -35,10 +35,7 @@ function BranchIcon() {
flexShrink: 0, flexShrink: 0,
boxShadow: "0 4px 12px rgba(234,88,12,.3)", 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"> <i className="ti ti-building" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
<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>
</div> </div>
); );
} }

View File

@@ -1,121 +1,70 @@
"use client"; "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"; 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 { interface ArchivedBranchTableProps {
branches: ArchivedBranch[]; branches: ArchivedBranch[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (branch: ArchivedBranch) => void; onView: (branch: ArchivedBranch) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ───────────────────────────────────────────────────────────────
export function ArchivedBranchTable({ branches, loading, search, page, pages, onView, onPageChange }: ArchivedBranchTableProps) { 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> data={branches}
<span>الفرع</span> loading={loading}
<span>المدينة</span> search={search}
<span>الحالة</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> pages={pages}
<span style={{ textAlign: "center" }}>آخر تحديث</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyDescription={search ? `No results for "${search}"` : "No archived branches."}
</div> renderActions={(b) => <ActionButtons itemLabel={b.name} onView={() => onView(b)} />}
/>
{/* 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>
); );
} }

View File

@@ -12,9 +12,7 @@ interface ArchivedBranchesModalProps {
} }
const SearchIcon = ( const SearchIcon = (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
); );
export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) { export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {

View File

@@ -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"; "use client";
import { useCallback, useEffect, useState } from "react"; 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 }}> <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)} <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)" }}> 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"> <i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة سجل صيانة إضافة سجل صيانة
</button> </button>
</div> </div>

View File

@@ -1,19 +1,11 @@
"use client"; "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)", import { IconBtn, ReusableTable } from "../../UI";
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
}; import type { TableColumn } from "@/src/types/models";
const thStyle: React.CSSProperties = { import type { CarMaintenance } from "@/src/types/carMaintanance";
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)",
};
interface ArchivedCarMaintenanceTableProps { interface ArchivedCarMaintenanceTableProps {
records: CarMaintenance[]; records: CarMaintenance[];
@@ -30,85 +22,78 @@ interface ArchivedCarMaintenanceTableProps {
export function ArchivedCarMaintenanceTable({ export function ArchivedCarMaintenanceTable({
records, loading, search, page, pages, showCar = false, onView, onPageChange, records, loading, search, page, pages, showCar = false, onView, onPageChange,
}: ArchivedCarMaintenanceTableProps) { }: 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 ( return (
<div style={cardStyle}> <ReusableTable
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: columns, ...thStyle }}> columns={columns}
<span>سبب الصيانة</span> data={records}
{showCar && <span>المركبة</span>} loading={loading}
<span>التكلفة</span> search={search}
<span>تاريخ البدء</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الانتهاء</span> pages={pages}
<span style={{ textAlign: "center" }}>إجراءات</span> onPageChange={onPageChange}
</div> actionsWidth="100px"
onRowClick={onView}
{loading ? ( emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> renderActions={(r) => (
<Spinner size="sm" className="text-blue-600" /> <IconBtn
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> title={`عرض ${r.reason}`}
</div> color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
) : records.length === 0 ? ( onClick={() => onView(r)}
<EmptyState >
icon="🔧" <i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."} </IconBtn>
/>
) : (
<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>
)} )}
/>
{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>
); );
} }

View File

@@ -51,9 +51,7 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
icon={ icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
} }
/> />
</div> </div>

View File

@@ -1,18 +1,35 @@
"use client"; "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 { IconBtn, ReusableTable, Button } from "../UI";
import { Button, EmptyState, IconBtn, Spinner } from "../UI"; import type { TableColumn } from "@/src/types/models";
import type { Client } from "@/src/types/client"; import type { Client } from "@/src/types/client";
// ── Address count badge ──────────────────────────────────────────────────── // ── Address count badge ────────────────────────────────────────────────────
function AddressBadge({ count, onClick }: { count: number; onClick: () => void }) { function AddressBadge({
count,
onClick,
}: {
count: number;
onClick: () => void;
}) {
const hasAddr = count > 0; const hasAddr = count > 0;
return ( return (
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); onClick(); }} onClick={(e) => {
title="إدارة العناوين" e.stopPropagation();
onClick();
}}
title="ادارة العناوين"
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
@@ -29,25 +46,13 @@ function AddressBadge({ count, onClick }: { count: number; onClick: () => void }
transition: "opacity 150ms", transition: "opacity 150ms",
}} }}
> >
{count} {count === 1 ? "عنوان" : "عناوين"} {count} {count === 1 ? "address" : "العناوين"}
{/* Small arrow icon to hint navigation */} <i className="ti ti-chevron-right" style={{ fontSize: 10 }} aria-hidden="true" />
<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>
</button> </button>
); );
} }
// ── Client detail panel (inline expandable) ──────────────────────────────── // ── Client detail panel (the expandable content) ───────────────────────────
/**
* 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.
*/
function ClientDetailPanel({ function ClientDetailPanel({
client, client,
onGoAddresses, onGoAddresses,
@@ -85,10 +90,8 @@ function ClientDetailPanel({
return ( return (
<div <div
dir="rtl"
style={{ style={{
/* Slide-down visual treatment: left accent border in brand blue */ borderInlineStart: "3px solid var(--color-brand-600)",
borderRight: "3px solid var(--color-brand-600)",
background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)", background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)",
borderBottom: "1px solid #BFDBFE", borderBottom: "1px solid #BFDBFE",
padding: "1.25rem 1.5rem", padding: "1.25rem 1.5rem",
@@ -98,7 +101,6 @@ function ClientDetailPanel({
animation: "detailFadeIn 180ms ease", animation: "detailFadeIn 180ms ease",
}} }}
> >
{/* ── Top row: name + quick actions ── */}
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -138,36 +140,29 @@ function ClientDetailPanel({
)} )}
</div> </div>
{/* Action buttons */}
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}> <div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
{/* Edit client */}
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
size="sm" 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"> <i className="ti ti-edit" style={{ fontSize: 12 }} aria-hidden="true" />
<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>
{/*
* "Addresses" button — KEY FEATURE:
* Navigates to /dashboard/clients/[clientId]/addresses
*/}
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
size="sm" 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"> <i className="ti ti-map-pin" style={{ fontSize: 12 }} aria-hidden="true" />
<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>
العناوين العناوين
{client.addresses?.length !== undefined && ( {client.addresses?.length !== undefined && (
<span <span
@@ -187,7 +182,6 @@ function ClientDetailPanel({
</div> </div>
</div> </div>
{/* ── Info grid ── */}
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -198,7 +192,7 @@ function ClientDetailPanel({
}} }}
> >
{infoItem( {infoItem(
"البريد الإلكتروني", "البريد الالكتروني",
<a <a
href={`mailto:${client.email}`} href={`mailto:${client.email}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -207,27 +201,26 @@ function ClientDetailPanel({
{client.email} {client.email}
</a>, </a>,
)} )}
{infoItem("الهاتف", client.phone)} {infoItem("رقم الجوال", client.phone)}
{infoItem( {infoItem(
"تاريخ الإضافة", "تاريخ الانشاء",
new Date(client.createdAt).toLocaleDateString("ar-SA", { new Date(client.createdAt).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}), }),
)} )}
{infoItem( {infoItem(
"آخر تحديث", "اخر تحديث",
new Date(client.updatedAt).toLocaleDateString("ar-SA", { new Date(client.updatedAt).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}), }),
)} )}
{client.notes && infoItem("ملاحظات", client.notes)} {client.notes && infoItem("Notes", client.notes)}
</div> </div>
{/* Keyframe animation injected via a style tag (scoped) */}
<style>{` <style>{`
@keyframes detailFadeIn { @keyframes detailFadeIn {
from { opacity: 0; transform: translateY(-6px); } 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 ────────────────────────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────────────────────────
interface ClientTableProps { interface ClientTableProps {
clients: Client[]; clients: Client[];
@@ -269,7 +242,6 @@ interface ClientTableProps {
onDelete: (client: Client) => void; onDelete: (client: Client) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
/** Navigate to address management for a client */
onManageAddresses: (client: Client) => void; onManageAddresses: (client: Client) => void;
} }
@@ -286,311 +258,155 @@ export function ClientTable({
onPageChange, onPageChange,
onManageAddresses, onManageAddresses,
}: ClientTableProps) { }: ClientTableProps) {
/** const columns: TableColumn<Client>[] = [
* expandedId — tracks which client row is currently expanded. {
* Clicking the same row again collapses it (toggle behaviour). key: "name",
*/ header: "اسم العميل",
const [expandedId, setExpandedId] = useState<string | null>(null); width: "2fr",
render: (c, _i, isExpanded) => (
const handleRowClick = (client: Client) => { <div>
// Toggle: collapse if already open, expand otherwise <p
setExpandedId((prev) => (prev === client.id ? null : client.id)); 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* Column headers */} columns={columns}
<div data={clients}
dir="rtl" loading={loading}
style={{ search={search}
display: "grid", page={page}
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px", pages={pages}
...thStyle, onPageChange={onPageChange}
}} actionsWidth="80px"
> emptyIcon="👥"
<span>العميل</span> emptyTitle={
<span>البريد الإلكتروني</span> search ? `No results for "${search}"` : "No clients to display."
<span>الهاتف</span> }
<span>العناوين</span> emptyAction={
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span> !search && (
<span style={{ textAlign: "center" }}>إجراءات</span> <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
</div> Add first client
</Button>
{/* Loading */} )
{loading ? ( }
<div renderActions={(c) => (
style={{ <>
display: "flex", <IconBtn
alignItems: "center", title={`Edit ${c.name}`}
justifyContent: "center", color="#1D4ED8"
gap: 12, bg="#EFF6FF"
padding: "4rem 0", borderColor="#BFDBFE"
color: "var(--color-text-muted)", onClick={() => onEdit(c)}
}} >
> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<Spinner size="sm" className="text-blue-600" /> </IconBtn>
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> <IconBtn
</div> title={`Delete ${c.name}`}
) : clients.length === 0 ? ( color="#DC2626"
/* Empty state */ bg="#FEF2F2"
<EmptyState borderColor="#FECACA"
icon="👥" onClick={() => onDelete(c)}
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء لعرضهم."} >
action={ <i className="ti ti-trash" style={{ fontSize: 14 }} aria-hidden="true" />
!search && ( </IconBtn>
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}> </>
أضف أول عميل )}
</Button> 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>
); );
} }

View File

@@ -1,112 +1,76 @@
"use client"; "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"; 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 { interface ArchivedClientTableProps {
clients: ArchivedClient[]; clients: ArchivedClient[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (client: ArchivedClient) => void; onView: (client: ArchivedClient) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ───────────────────────────────────────────────────────────────
export function ArchivedClientTable({ clients, loading, search, page, pages, onView, onPageChange }: ArchivedClientTableProps) { 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> data={clients}
<span>العميل</span> loading={loading}
<span>البريد الإلكتروني</span> search={search}
<span>الحالة</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> pages={pages}
<span style={{ textAlign: "center" }}>آخر تحديث</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyIcon="👥"
</div> emptyDescription={search ? `No results for "${search}"` : "No archived clients."}
renderActions={(c) => <ActionButtons itemLabel={c.name} onView={() => onView(c)} />}
{/* 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>
); );
} }

View File

@@ -42,9 +42,7 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
icon={ icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
} }
/> />
</div> </div>

View File

@@ -1,5 +1,5 @@
// src/Components/Client/index.ts // src/Components/Client/index.ts
// CHANGE: removed DeleteConfirmModal export (Issue 1). // CHANGE: removed DeleteConfirmModal export (Issue 1).
export { ClientFormModal } from "./Clientformmodal"; export { ClientFormModal } from "./Clientformmodal";
export { ClientTable } from "./Clienttable"; export { ClientTable } from "./ClientTable";
export { AddressFormModal } from "../Client_Adress/Addressformmodal"; export { AddressFormModal } from "../Client_Adress/Addressformmodal";

View File

@@ -1,19 +1,12 @@
"use client"; "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"; import type { ArchivedClientAddress } from "@/src/types/client_adresses";
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = { type Row = ArchivedClientAddress & { id: string };
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 ──────────────────────────────────────────────────────────────────── // ── Props ────────────────────────────────────────────────────────────────────
interface ArchivedClientAddressesTableProps { interface ArchivedClientAddressesTableProps {
@@ -25,73 +18,87 @@ interface ArchivedClientAddressesTableProps {
// ── main table — no pagination footer: the endpoint returns the full set ───── // ── main table — no pagination footer: the endpoint returns the full set ─────
export function ArchivedClientAddressesTable({ addresses, loading, search, onView }: ArchivedClientAddressesTableProps) { export function ArchivedClientAddressesTable({ addresses, loading, search, onView }: ArchivedClientAddressesTableProps) {
return ( const rows: Row[] = addresses.map((a) => ({ ...a, id: a._id }));
<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>
{/* loading state */} const columns: TableColumn<Row>[] = [
{loading ? ( {
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> key: "type",
<Spinner size="sm" className="text-blue-600" /> header: "النوع",
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> 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> </div>
) : addresses.length === 0 ? ( ),
/* empty state */ },
<EmptyState {
icon="📍" key: "address",
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."} 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) => ( key: "createdAt",
<li key={a._id} style={{ header: "تاريخ الإنشاء",
display: "grid", gridTemplateColumns: "1.5fr 2fr 1.2fr 1fr 1fr 100px", width: "1fr",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", render: (a) => (
borderBottom: "1px solid var(--color-border)", <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", {new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
fontSize: 13, </span>
}}> ),
<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> return (
)} <ReusableTable
</div> columns={columns}
<span style={{ color: "var(--color-text-secondary)" }}> data={rows}
{a.details.street}، {a.details.city} loading={loading}
</span> search={search}
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}> page={1}
{a.contactPerson?.name ?? "—"} pages={1}
</span> onPageChange={() => {}}
<Badge actionsWidth="100px"
label={a.isValidated ? "موثّق" : "غير موثّق"} onRowClick={onView}
color={a.isValidated ? "green" : "slate"} emptyDescription={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
/> renderActions={(a) => (
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}> // view only — matches archived users/clients: no delete/restore yet
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} <IconBtn
</span> title={`عرض ${a.label}`}
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}> color="#059669" bg="#ECFDF5" borderColor="#A7F3D0"
{/* view only — matches archived users/clients: no delete/restore yet */} onClick={() => onView(a)}
<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"> <i className="ti ti-eye" style={{ fontSize: 14 }} aria-hidden="true" />
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> </IconBtn>
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)} )}
</div> />
); );
} }

View File

@@ -46,9 +46,7 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
icon={ icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
} }
/> />
</div> </div>

View File

@@ -120,18 +120,7 @@ function PhotoCard({ url, label }: { url?: string | null; label: string }) {
onError={() => setImgError(true)} onError={() => setImgError(true)}
/> />
) : ( ) : (
<svg <i className="ti ti-photo" style={{ fontSize: 28, color: "var(--color-text-hint)" }} aria-hidden="true" />
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>
)} )}
</div> </div>
{url && !imgError && ( {url && !imgError && (
@@ -456,10 +445,7 @@ export function DriverDetailPanel({
</Button> </Button>
<Button type="button" variant="secondary" onClick={() => onEdit(driver)}> <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"> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<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>

View File

@@ -79,18 +79,7 @@ export function PhotoCard({
/> />
</> </>
) : ( ) : (
<svg <i className="ti ti-photo" style={{ fontSize: 32, color: "var(--color-text-hint)" }} aria-hidden="true" />
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)} )}
</div> </div>
{url && !imgError && ( {url && !imgError && (
@@ -110,4 +99,4 @@ export function PhotoCard({
)} )}
</div> </div>
); );
} }

View 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)}
/>
)}
/>
);
}

View File

@@ -1,142 +1,86 @@
"use client"; "use client";
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
import { ReusableTable, ActionButtons } from "../../UI";
import { DRIVER_STATUS_MAP } from "@/src/types/driver"; import { DRIVER_STATUS_MAP } from "@/src/types/driver";
import type { TableColumn } from "@/src/types/models";
import type { ArchivedDriver } from "@/src/types/driver"; 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 { interface ArchivedDriverTableProps {
drivers: ArchivedDriver[]; drivers: ArchivedDriver[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (driver: ArchivedDriver) => void; onView: (driver: ArchivedDriver) => void;
onPageChange: (p: number) => 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) { export function ArchivedDriverTable({ drivers, loading, search, page, pages, onView, onPageChange }: ArchivedDriverTableProps) {
return ( const columns: TableColumn<ArchivedDriver>[] = [
<div style={cardStyle}> {
{/* column headers */} key: "name",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}> header: "الاسم",
<span>الاسم</span> width: "2fr",
<span>اسم المستخدم</span> render: (d) => (
<span>الحالة</span> <div>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
<span style={{ textAlign: "center" }}>آخر تحديث</span> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
<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> </div>
) : drivers.length === 0 ? ( ),
/* empty state */ },
<EmptyState {
icon="🗄️" key: "userName",
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."} header: "اسم السائق",
/> width: "1.5fr",
) : ( render: (d) => (
/* data rows */ <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
<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, key: "status",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", header: "الحالة",
borderBottom: "1px solid var(--color-border)", width: "1fr",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", render: (d) => {
fontSize: 13, cursor: "pointer", const s = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive;
}}> return (
<div> <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 }}>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p> <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p> {s.label}
</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>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> );
<Button },
type="button" },
variant="secondary" {
size="sm" key: "createdAt",
onClick={() => onPageChange(Math.max(1, page - 1))} header: "تاريخ الانشاء",
disabled={page === 1} width: "1fr",
> align: "center",
السابق render: (d) => new Date(d.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
</Button> },
<Button {
type="button" key: "updatedAt",
variant="secondary" header: "تاريخ التعديل",
size="sm" width: "1fr",
onClick={() => onPageChange(Math.min(pages, page + 1))} align: "center",
disabled={page === pages} render: (d) => new Date(d.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }),
> },
التالي ];
</Button>
</div> return (
</div> <ReusableTable
)} columns={columns}
</div> 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)} />}
/>
); );
} }

View File

@@ -11,18 +11,7 @@ interface ArchivedDriversProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
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>
); );
/** /**

View File

@@ -222,10 +222,7 @@ export function OrderDetailPanel({
justifyContent: "center", justifyContent: "center",
}} }}
> >
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand-600)" strokeWidth="2"> <i className="ti ti-package" style={{ fontSize: 24, color: "var(--color-brand-600)" }} aria-hidden="true" />
<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>
</div> </div>
<div> <div>
@@ -460,10 +457,7 @@ export function OrderDetailPanel({
</Button> </Button>
<Button type="button" variant="secondary" onClick={() => onEdit(order)}> <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"> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<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>

View File

@@ -372,10 +372,7 @@ export function OrderFormModal({
))} ))}
</Select> </Select>
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}> <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"> <i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء عميل جديد إنشاء عميل جديد
</Link> </Link>
</div> </div>
@@ -418,10 +415,7 @@ export function OrderFormModal({
))} ))}
</Select> </Select>
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}> <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"> <i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
إنشاء رحلة جديدة إنشاء رحلة جديدة
</Link> </Link>
</div> </div>

View File

@@ -1,60 +1,20 @@
"use client"; "use client";
import { Spinner } from "../UI"; import { ReusableTable, ActionButtons } from "../UI";
import type { Order } from "@/src/types/order";
import { ORDER_STATUS_MAP } from "./OrderDetailModel"; import { ORDER_STATUS_MAP } from "./OrderDetailModel";
import type { TableColumn } from "@/src/types/models";
// ── Helpers — copied verbatim from app/dashboard/drivers/page.tsx ─────── import type { Order } from "@/src/types/order";
function fmtDate(iso?: string | null): string { function fmtDate(iso?: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", { return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
year: "numeric", month: "short", day: "numeric",
});
} }
function fmtAmount(n?: number | null): string { function fmtAmount(n?: number | null): string {
if (n == null) return "—"; 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 { interface OrderTableProps {
orders: Order[]; orders: Order[];
loading: boolean; loading: boolean;
@@ -68,181 +28,86 @@ interface OrderTableProps {
} }
export function OrderTable({ export function OrderTable({
orders, orders, loading, search, page, pages,
loading, setPage, onRowClick, onEdit, onDelete,
search,
page,
pages,
setPage,
onRowClick,
onEdit,
onDelete,
}: OrderTableProps) { }: OrderTableProps) {
return ( const columns: TableColumn<Order>[] = [
<div style={cardStyle}> {
<div key: "shipmentNumber",
dir="rtl" header: "رقم الشحنة #",
style={{ width: "1.6fr",
display: "grid", render: (o) => (
gridTemplateColumns: ROW_GRID_COLUMNS, <div>
...thStyle, <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>
>
<span>رقم الشحنة</span>
<span>المستلم</span>
<span>العميل</span>
<span>المبلغ</span>
<span>الحالة</span>
<span>إجراءات</span>
</div>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div> </div>
) : orders.length === 0 ? ( ),
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}> },
لا توجد نتائج {search && `لـ "${search}"`} {
</p> key: "recipient",
) : ( header: "المستلم",
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> width: "1.6fr",
{orders.map((o, i) => { render: (o) => (
const statusCfg = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created; <div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
return ( <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
<li </div>
key={o.id} ),
onClick={() => onRowClick(o.id)} },
style={{ { key: "client", header: "العميل", width: "1.2fr", render: (o) => o.client?.name ?? "—" },
display: "grid", {
gridTemplateColumns: ROW_GRID_COLUMNS, key: "amount",
alignItems: "center", gap: "0.5rem", header: "الكمية",
padding: "0.875rem 1.5rem", width: "1.1fr",
borderBottom: "1px solid var(--color-border)", render: (o) => (
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", <span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
fontSize: 13, {fmtAmount(o.totalPrice)}
cursor: "pointer", </span>
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")} key: "status",
> header: "الحالة",
<div> width: "1fr",
<p style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB", margin: 0 }}>{o.shipmentNumber}</p> render: (o) => {
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(o.createdAt)}</p> const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
</div> return (
<div> <span style={{
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p> display: "inline-flex", alignItems: "center", gap: 5,
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p> borderRadius: "var(--radius-full)",
</div> border: `1px solid ${s.border}`,
<span style={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span> background: s.bg,
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}> padding: "0.2rem 0.625rem",
{fmtAmount(o.totalPrice)} fontSize: 11, fontWeight: 600, color: s.color,
</span> width: "fit-content",
<span style={{ }}>
display: "inline-flex", alignItems: "center", gap: 5, <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
borderRadius: "var(--radius-full)", {s.label}
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>
</span> </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 return (
key={btn.label} <ReusableTable
onClick={btn.action} columns={columns}
disabled={btn.disabled} data={orders}
style={{ loading={loading}
borderRadius: "var(--radius-lg)", search={search}
border: "1px solid var(--color-border)", page={page}
background: "var(--color-surface-muted)", pages={pages}
padding: "0.375rem 0.875rem", onPageChange={setPage}
fontSize: 12, color: "var(--color-text-secondary)", actionsWidth="0.8fr"
cursor: btn.disabled ? "not-allowed" : "pointer", onRowClick={(o) => onRowClick(o.id)}
opacity: btn.disabled ? 0.4 : 1, emptyDescription={search ? `No results for "${search}"` : undefined}
fontFamily: "var(--font-sans)", renderActions={(o) => (
}} <ActionButtons
> itemLabel={o.shipmentNumber}
{btn.label} onEdit={() => onEdit(o)}
</button> onDelete={() => onDelete(o)}
))} />
</div>
</div>
)} )}
</div> />
); );
} }

View File

@@ -1,132 +1,80 @@
"use client"; "use client";
import { Button, EmptyState, IconBtn, Spinner } from "../../UI"; import { ReusableTable, ActionButtons } from "../../UI";
import { ORDER_STATUS_MAP } from "../OrderDetailModel"; import { ORDER_STATUS_MAP } from "../OrderDetailModel";
import type { TableColumn } from "@/src/types/models";
import type { ArchivedOrder } from "@/src/types/order"; import type { ArchivedOrder } from "@/src/types/order";
function fmtAmount(n?: string | number | null): string { function fmtAmount(n?: string | number | null): string {
if (n == null) return "—"; if (n == null) return "—";
const num = typeof n === "string" ? parseFloat(n) : n; const num = typeof n === "string" ? parseFloat(n) : n;
if (Number.isNaN(num)) return "—"; 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 { interface ArchivedOrderTableProps {
orders: ArchivedOrder[]; orders: ArchivedOrder[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (order: ArchivedOrder) => void; onView: (order: ArchivedOrder) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
export function ArchivedOrderTable({ orders, loading, search, page, pages, onView, onPageChange }: ArchivedOrderTableProps) { export function ArchivedOrderTable({ orders, loading, search, page, pages, onView, onPageChange }: ArchivedOrderTableProps) {
return ( const columns: TableColumn<ArchivedOrder>[] = [
<div style={cardStyle}> {
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID, ...thStyle }}> key: "shipmentNumber",
<span>رقم الشحنة</span> header: "رقم الشحنة #",
<span>المستلم</span> width: "1.6fr",
<span>المبلغ</span> render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>{o.shipmentNumber}</span>,
<span>الحالة</span> },
<span style={{ textAlign: "center" }}>إجراءات</span> {
</div> key: "recipient",
header: "المستلم",
{loading ? ( width: "1.6fr",
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> render: (o) => (
<Spinner size="sm" className="text-blue-600" /> <div>
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> <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> </div>
) : orders.length === 0 ? ( ),
<EmptyState },
icon="🗄️" {
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."} key: "amount",
/> header: "الكمية",
) : ( width: "1fr",
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> render: (o) => <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>{fmtAmount(o.subTotal)}</span>,
{orders.map((o, i) => { },
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created; {
return ( key: "status",
<li key={o.id} style={{ header: "الحالة",
display: "grid", gridTemplateColumns: ROW_GRID, width: "1fr",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", render: (o) => {
borderBottom: "1px solid var(--color-border)", const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", return (
fontSize: 13, <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 }} />
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}> {s.label}
{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>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> );
<Button },
type="button" },
variant="secondary" ];
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))} return (
disabled={page === 1} <ReusableTable
> columns={columns}
السابق data={orders}
</Button> loading={loading}
<Button search={search}
type="button" page={page}
variant="secondary" pages={pages}
size="sm" onPageChange={onPageChange}
onClick={() => onPageChange(Math.min(pages, page + 1))} emptyIcon="🗄️"
disabled={page === pages} emptyDescription={search ? `No results for "${search}"` : "No archived orders."}
> renderActions={(o) => <ActionButtons itemLabel={o.shipmentNumber} onView={() => onView(o)} />}
التالي />
</Button>
</div>
</div>
)}
</div>
); );
} }

View File

@@ -12,18 +12,7 @@ interface ArchivedOrdersModalProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
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>
); );
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) { export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {

View File

@@ -1,8 +1,5 @@
"use client"; "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 { useEffect, useState } from "react";
import { Alert, Button, Modal, Spinner } from "../UI"; import { Alert, Button, Modal, Spinner } from "../UI";

View File

@@ -1,17 +1,15 @@
"use client"; "use client";
// src/Components/Trip/TripTable.tsx // src/Components/Trip/TripTable.tsx
// New — split out of the old single-file Trip module, mirroring // MIGRATED to ReusableTable + ActionButtons — same props, same visual
// UserTable.tsx for consistency. // 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 { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { TableColumn } from "@/src/types/models";
import type { Trip } from "@/src/types/trip"; 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"] }) { function StatusBadge({ status }: { status: Trip["status"] }) {
const s = TRIP_STATUS_MAP[status]; const s = TRIP_STATUS_MAP[status];
return ( 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 { function fmtDate(iso?: string | null): string {
if (!iso) return "—"; 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 { interface TripTableProps {
trips: Trip[]; trips: Trip[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onEdit: (trip: Trip) => void; onEdit: (trip: Trip) => void;
onDelete: (trip: Trip) => void; onDelete: (trip: Trip) => void;
onView: (trip: Trip) => void; onView: (trip: Trip) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ─────────────────────────────────────────────────────────────── export function TripTable({
export function TripTable({ trips, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: TripTableProps) { 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}> data={trips}
<span>الرحلة</span> loading={loading}
<span>السائق</span> search={search}
<span>السيارة</span> page={page}
<span>الفرع</span> pages={pages}
<span>الحالة</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>وقت البدء</span> emptyIcon="🚚"
<span style={{ textAlign: "center" }}>إجراءات</span> emptyDescription={!search ? "No trips to display." : undefined}
</div> emptyAction={
!search && (
{/* loading state */} <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
{loading ? ( Add first trip
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> </Button>
<Spinner size="sm" className="text-blue-600" /> )
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> }
</div> renderActions={(t) => (
) : trips.length === 0 ? ( <ActionButtons
/* empty state */ itemLabel={t.title}
<EmptyState onView={() => onView(t)}
icon="🚚" onEdit={() => onEdit(t)}
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد رحلات لعرضها."} onDelete={() => onDelete(t)}
action={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
أضف أول رحلة
</Button>
)
}
/> />
) : (
/* 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>
); );
} }

View File

@@ -1,17 +1,5 @@
"use client"; "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 { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";

View File

@@ -1,9 +1,6 @@
"use client"; "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 { useState } from "react";
import { Alert, Input, Modal } from "@/src/Components/UI"; import { Alert, Input, Modal } from "@/src/Components/UI";
@@ -16,18 +13,7 @@ interface ArchivedTripModalProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
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>
); );
/** /**

View File

@@ -1,135 +1,79 @@
"use client"; "use client";
// src/Components/Trip/archive/ArchivedTripTable.tsx import { ReusableTable } from "@/src/Components/UI";
// 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 { TRIP_STATUS_MAP } from "@/src/types/trip"; import { TRIP_STATUS_MAP } from "@/src/types/trip";
import type { TableColumn } from "@/src/types/models";
import type { Trip } from "@/src/types/trip"; 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 { function fmtDate(iso?: string | null): string {
if (!iso) return "—"; 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 { interface ArchivedTripTableProps {
trips: Trip[]; trips: Trip[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (trip: Trip) => void; onView: (trip: Trip) => void;
onPageChange: (p: number) => 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) { export function ArchivedTripTable({ trips, loading, search, page, pages, onView, onPageChange }: ArchivedTripTableProps) {
return ( const columns: TableColumn<Trip>[] = [
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"> {
{/* column headers */} key: "title",
<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)]"> header: "الرحلة",
<span>الرحلة</span> width: "2fr",
<span>الحالة</span> render: (t) => (
<span className="text-center">البدء</span> <div>
<span className="text-center">الانتهاء</span> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
<span className="text-center">النقد المحصّل</span> <p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-brand-600)" }}>{t.tripNumber}</p>
</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>
</div> </div>
) : trips.length === 0 ? ( ),
/* ── Empty state ── */ },
<EmptyState {
icon="🗄️" key: "status",
title="لا توجد رحلات مؤرشفة" header: "الحالة",
description={search ? `لا توجد نتائج لـ "${search}"` : "لم يتم أرشفة أي رحلات بعد."} width: "1.5fr",
/> render: (t) => {
) : ( const s = TRIP_STATUS_MAP[t.status];
/* ── Data rows ── */ return (
<ul className="m-0 list-none p-0"> <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 }}>
{trips.map((trip, i) => ( <span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
<li {s.label}
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>
</span> </span>
<div className="flex gap-2"> );
<Button },
variant="secondary" },
size="sm" { key: "start", header: "تاريخ البداية", width: "1fr", align: "center", render: (t) => fmtDate(t.startTime) },
disabled={page === 1} { key: "end", header: "تاريخ الانتهاء", width: "1fr", align: "center", render: (t) => fmtDate(t.endTime) },
onClick={() => onPageChange(Math.max(1, page - 1))} {
> key: "cash",
السابق header: "المبلغ المحصل",
</Button> width: "1fr",
<Button align: "center",
variant="secondary" render: (t) => (
size="sm" <span style={{ fontWeight: 600, fontFamily: "var(--font-mono)" }}>
disabled={page === pages} {t.totalCashCollected != null ? `${Number(t.totalCashCollected).toLocaleString("en-US")} SAR` : "—"}
onClick={() => onPageChange(Math.min(pages, page + 1))} </span>
> ),
التالي },
</Button> ];
</div>
</div> return (
)} <ReusableTable
</div> 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."}
/>
); );
} }

View 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>
)}
</>
);
}

View File

@@ -50,11 +50,7 @@ export function ArchiveButton({
onMouseEnter={e => (e.currentTarget.style.transform = "translateY(-2px)")} onMouseEnter={e => (e.currentTarget.style.transform = "translateY(-2px)")}
onMouseLeave={e => (e.currentTarget.style.transform = "translateY(0)")} onMouseLeave={e => (e.currentTarget.style.transform = "translateY(0)")}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-archive" style={{ fontSize: 16 }} aria-hidden="true" />
<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>
{label} {label}
</button> </button>
); );

View File

@@ -122,17 +122,7 @@ export default function Header({
</Select> </Select>
) : ( ) : (
<Button onClick={onAdd}> <Button onClick={onAdd}>
<svg <i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" />
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>
إضافة {name} إضافة {name}
</Button> </Button>
)} )}

View 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>
);
}

View 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>
);
}

View File

@@ -7,10 +7,11 @@ export interface SpinnerProps {
className?: string; className?: string;
} }
// Font size (px) for each spinner size — replaces the old h-*/w-* SVG classes.
const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = { const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
sm: "h-4 w-4", sm: "16px",
md: "h-6 w-6", md: "24px",
lg: "h-10 w-10 border-4", lg: "40px",
}; };
/** /**
@@ -26,32 +27,15 @@ const sizeMap: Record<NonNullable<SpinnerProps["size"]>, string> = {
*/ */
export function Spinner({ size = "md", className }: SpinnerProps) { export function Spinner({ size = "md", className }: SpinnerProps) {
return ( return (
<svg <i
role="status" role="status"
aria-label="Loading" aria-label="Loading"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className={cn( className={cn(
"motion-safe:animate-spin text-[var(--color-brand-600)] shrink-0", "ti ti-loader-2 motion-safe:animate-spin text-[var(--color-brand-600)] shrink-0",
sizeMap[size],
className, className,
)} )}
> style={{ fontSize: sizeMap[size], lineHeight: 1 }}
<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>
); );
} }

View File

@@ -25,4 +25,7 @@ export { Badge } from "./Badge";
export { ArchiveButton } from "./ArchiveButton" export { ArchiveButton } from "./ArchiveButton"
export { FileInput } from "./FailInput" export { FileInput } from "./FailInput"
export {IconBtn} from "./IconBtn" export {IconBtn} from "./IconBtn"
export { ReusableTable } from "./ReusableTable";
export { ActionButtons } from "./ActionButtons";
export { ModelCard } from "./ModelCard";

View File

@@ -1,158 +1,115 @@
"use client"; "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"; 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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( 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" }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"} {active ? "Active" : "Disabled"}
</span> </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 { interface UserTableProps {
users: User[]; users: User[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onEdit: (user: User) => void; onEdit: (user: User) => void;
onDelete: (user: User) => void; onDelete: (user: User) => void;
onView: (user: User) => void; onView: (user: User) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ─────────────────────────────────────────────────────────────── export function UserTable({
export function UserTable({ users, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: UserTableProps) { 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> data={users}
<span>الاسم</span> loading={loading}
<span>اسم المستخدم</span> search={search}
<span>الفرع</span> page={page}
<span>الدور</span> pages={pages}
<span>الحالة</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> emptyIcon="👥"
<span style={{ textAlign: "center" }}>إجراءات</span> emptyDescription={!search ? "No users to display." : undefined}
</div> emptyAction={
!search && (
{/* loading state */} <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
{loading ? ( Add first user
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> </Button>
<Spinner size="sm" className="text-blue-600" /> )
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> }
</div> renderActions={(u) => (
) : users.length === 0 ? ( <ActionButtons
/* empty state */ itemLabel={u.name}
<EmptyState onView={() => onView(u)}
icon="👥" onEdit={() => onEdit(u)}
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."} onDelete={() => onDelete(u)}
action={
!search && (
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
أضف أول مستخدم
</Button>
)
}
/> />
) : (
/* 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>
); );
} }

View File

@@ -11,18 +11,7 @@ interface ArchivedUsersModalProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
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>
); );
export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) { export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {

View File

@@ -1,134 +1,79 @@
"use client"; "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"; 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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( 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" }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"} {active ? "Active" : "Disabled"}
</span> </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 { interface ArchivedUserTableProps {
users: ArchivedUser[]; users: ArchivedUser[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (user: ArchivedUser) => void; onView: (user: ArchivedUser) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ───────────────────────────────────────────────────────────────
export function ArchivedUserTable({ users, loading, search, page, pages, onView, onPageChange }: ArchivedUserTableProps) { 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> data={users}
<span>الاسم</span> loading={loading}
<span>اسم المستخدم</span> search={search}
<span>الحالة</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> pages={pages}
<span style={{ textAlign: "center" }}>آخر تحديث</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyIcon="🗄️"
</div> emptyDescription={search ? `No results for "${search}"` : "No archived users."}
renderActions={(u) => <ActionButtons itemLabel={u.name} onView={() => onView(u)} />}
{/* 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>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@@ -229,11 +229,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
{/* Row 1: secondary views */} {/* Row 1: secondary views */}
<div style={{ display: "flex", gap: "0.75rem" }}> <div style={{ display: "flex", gap: "0.75rem" }}>
<Button type="button" variant="secondary" style={{ flex: 1 }} onClick={() => setGallery(true)}> <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"> <i className="ti ti-photo" style={{ fontSize: 16 }} aria-hidden="true" />
<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>
الصور الصور
</Button> </Button>
<Button <Button
@@ -242,9 +238,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
style={{ flex: 1 }} style={{ flex: 1 }}
onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)} onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-tool" style={{ fontSize: 16 }} aria-hidden="true" />
<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>
عرض تاريخ الصيانة عرض تاريخ الصيانة
</Button> </Button>
</div> </div>
@@ -252,10 +246,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
{/* Row 2: primary edit / delete */} {/* Row 2: primary edit / delete */}
<div style={{ display: "flex", gap: "0.75rem" }}> <div style={{ display: "flex", gap: "0.75rem" }}>
<Button type="button" variant="primary" style={{ flex: 2 }} onClick={() => onEdit(car)}> <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"> <i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" />
<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>
<Button type="button" variant="danger" onClick={() => onDelete(car)}> <Button type="button" variant="danger" onClick={() => onDelete(car)}>

View File

@@ -120,9 +120,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
loading={uploading} loading={uploading}
> >
{!uploading && ( {!uploading && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <i className="ti ti-upload" style={{ fontSize: 14 }} aria-hidden="true" />
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
)} )}
{uploading ? "جارٍ الرفع…" : "رفع صور"} {uploading ? "جارٍ الرفع…" : "رفع صور"}
</Button> </Button>
@@ -224,10 +222,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
> >
{isDeleting {isDeleting
? <Spinner size="sm" className="text-red-600" /> ? <Spinner size="sm" className="text-red-600" />
: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> : <i className="ti ti-trash" style={{ fontSize: 12 }} aria-hidden="true" />
<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>
} }
</IconBtn> </IconBtn>
</div> </div>

View File

@@ -48,9 +48,7 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
icon={ icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
} }
/> />
</div> </div>

View File

@@ -1,20 +1,17 @@
"use client"; "use client";
import { Button, EmptyState, IconBtn, Spinner } from "../../UI"; // src/Components/Car/archive/ArchivedCarTable.tsx
import { STATUS_MAP, fmtDateShort } from "@/src/types/car"; // Converted to use ReusableTable, matching DriverTable / OrderTable /
import type { Car } from "@/src/types/car"; // 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 ───────────────────────────────────────────────────── import { IconBtn, ReusableTable } from "../../UI";
const cardStyle: React.CSSProperties = { import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", import type { TableColumn } from "@/src/types/models";
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", import type { Car } from "@/src/types/car";
};
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 ──────────────────────────────────────────────────────────────────── // ── Props ────────────────────────────────────────────────────────────────────
interface ArchivedCarTableProps { interface ArchivedCarTableProps {
@@ -29,100 +26,89 @@ interface ArchivedCarTableProps {
// ── main table ─────────────────────────────────────────────────────────────── // ── main table ───────────────────────────────────────────────────────────────
export function ArchivedCarTable({ cars, loading, search, page, pages, onView, onPageChange }: ArchivedCarTableProps) { export function ArchivedCarTable({ cars, loading, search, page, pages, onView, onPageChange }: ArchivedCarTableProps) {
return ( const columns: TableColumn<Car>[] = [
<div style={cardStyle}> {
{/* column headers */} key: "vehicle",
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}> header: "المركبة",
<span>المركبة</span> width: "2fr",
<span>رقم اللوحة</span> render: (c) => (
<span>الفرع</span> <div>
<span>آخر حالة</span> <p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span> {c.manufacturer} {c.model}
<span style={{ textAlign: "center" }}>إجراءات</span> </p>
</div> <p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>
{c.year}{c.color ? ` · ${c.color}` : ""}
{/* loading state */} </p>
{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> </div>
) : cars.length === 0 ? ( ),
/* empty state */ },
<EmptyState {
icon="🚗" key: "plate",
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."} header: "رقم اللوحة",
/> width: "1.5fr",
) : ( render: (c) => (
/* data rows */ <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> {c.plateLetters} {c.plateNumber}
{cars.map((c, i) => { </span>
const status = STATUS_MAP[c.currentStatus]; ),
return ( },
<li key={c.id} style={{ { key: "branch", header: "الفرع", width: "1fr", render: (c) => c.branch?.name ?? "—" },
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", {
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", key: "status",
borderBottom: "1px solid var(--color-border)", header: "آخر حالة",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", width: "1fr",
fontSize: 13, render: (c) => {
}}> const status = STATUS_MAP[c.currentStatus];
<div> return (
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}> <span style={{
{c.manufacturer} {c.model} display: "inline-flex", alignItems: "center", gap: 5,
</p> borderRadius: "var(--radius-full)",
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{c.year}{c.color ? ` · ${c.color}` : ""}</p> border: `1px solid ${status.border}`,
</div> background: status.bg,
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}> padding: "0.2rem 0.625rem",
{c.plateLetters} {c.plateNumber} fontSize: 11, fontWeight: 700, color: status.color,
</span> width: "fit-content",
<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 }} />
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} /> {status.label}
{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>
</span> </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 => ( key: "createdAt",
<Button header: "تاريخ الإضافة",
key={btn.label} width: "1fr",
type="button" render: (c) => (
variant="secondary" <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
size="sm" {fmtDateShort(c.createdAt)}
onClick={btn.action} </span>
disabled={btn.disabled} ),
> },
{btn.label} ];
</Button>
))} return (
</div> <ReusableTable
</div> 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> />
); );
} }

View File

@@ -1,11 +1,9 @@
"use client"; "use client";
import { Role } from "@/src/types/role"; import { Button, ReusableTable, ActionButtons } from "../UI";
import { Button, EmptyState, IconBtn, Spinner } 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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ <span style={{
@@ -18,157 +16,88 @@ function StatusBadge({ active }: { active: boolean }) {
color: active ? "#166534" : "#991B1B", color: active ? "#166534" : "#991B1B",
}}> }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"} {active ? "Active" : "Disabled"}
</span> </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 { interface RoleTableProps {
roles: Role[]; roles: Role[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onEdit: (role: Role) => void; onEdit: (role: Role) => void;
onDelete: (role: Role) => void; onDelete: (role: Role) => void;
onView: (role: Role) => void; onView: (role: Role) => void;
onAddFirst: () => void; onAddFirst: () => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── Main table ────────────────────────────────────────────────────────────────
export function RoleTable({ export function RoleTable({
roles, loading, search, page, pages, roles, loading, search, page, pages,
onEdit, onDelete, onView, onAddFirst, onPageChange, onEdit, onDelete, onView, onAddFirst, onPageChange,
}: RoleTableProps) { }: 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* Column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 3fr 1fr 1fr 120px", ...thStyle }}> data={roles}
<span>اسم الدور</span> loading={loading}
<span>الوصف</span> search={search}
<span>الصلاحيات</span> page={page}
<span>الحالة</span> pages={pages}
<span style={{ textAlign: "center" }}>إجراءات</span> onPageChange={onPageChange}
</div> actionsWidth="120px"
emptyIcon="🛡️"
{/* Loading */} emptyTitle={search ? undefined : "No roles to display"}
{loading ? ( emptyDescription={!search ? "Start by creating the first role in the system" : undefined}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> emptyAction={
<Spinner size="sm" className="text-blue-600" /> !search && (
<span style={{ fontSize: 13 }}>جارٍ التحميل</span> <Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
</div> Add first role
) : roles.length === 0 ? ( </Button>
/* Empty state */ )
<EmptyState }
icon="🛡️" renderActions={(r) => (
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"} <ActionButtons
description={!search ? "ابدأ بإنشاء أول دور في النظام" : undefined} itemLabel={r.name}
action={ onView={() => onView(r)}
!search && ( onEdit={() => onEdit(r)}
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}> onDelete={() => onDelete(r)}
إضافة أول دور
</Button>
)
}
/> />
) : (
/* 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>
); );
} }

View File

@@ -59,10 +59,7 @@ function RoleIcon() {
flexShrink: 0, flexShrink: 0,
boxShadow: "0 4px 12px rgba(234,88,12,.3)", 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"> <i className="ti ti-shield-check" style={{ fontSize: 26, color: "#FFF" }} aria-hidden="true" />
<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>
</div> </div>
); );
} }

View File

@@ -1,131 +1,61 @@
"use client"; "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"; 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 }) { function StatusBadge({ active }: { active: boolean }) {
return ( 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" }} /> <span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"} {active ? "Active" : "Disabled"}
</span> </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 { interface ArchivedRoleTableProps {
roles: ArchivedRole[]; roles: ArchivedRole[];
loading: boolean; loading: boolean;
search: string; search: string;
page: number; page: number;
pages: number; pages: number;
onView: (role: ArchivedRole) => void; onView: (role: ArchivedRole) => void;
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── main table ───────────────────────────────────────────────────────────────
export function ArchivedRoleTable({ roles, loading, search, page, pages, onView, onPageChange }: ArchivedRoleTableProps) { 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 ( return (
<div style={cardStyle}> <ReusableTable
{/* column headers */} columns={columns}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 2fr 1fr 1fr 1fr 100px", ...thStyle }}> data={roles}
<span>الدور</span> loading={loading}
<span>الوصف</span> search={search}
<span>الحالة</span> page={page}
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span> pages={pages}
<span style={{ textAlign: "center" }}>آخر تحديث</span> onPageChange={onPageChange}
<span style={{ textAlign: "center" }}>إجراءات</span> emptyIcon="🛡️"
</div> emptyDescription={search ? `No results for "${search}"` : "No archived roles."}
renderActions={(r) => <ActionButtons itemLabel={r.name} onView={() => onView(r)} />}
{/* 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>
); );
} }

View File

@@ -11,18 +11,7 @@ interface ArchivedRolesModalProps {
} }
const searchIcon = ( const searchIcon = (
<svg <i className="ti ti-search" style={{ fontSize: 16 }} aria-hidden="true" />
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>
); );
export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) { export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { driverService } from "@/src/services/driver.service"; import { driverService } from "@/src/services/driver.service";
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver"; import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
@@ -59,11 +59,21 @@ export function useDrivers() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [notification, setNotification] = useState<ToastNotification | null>(null); 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) => { const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n); 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 ───────────────────────────────────────────────────────── // ── Fetch drivers ─────────────────────────────────────────────────────────
const loadDrivers = useCallback(async (p: number, q: string) => { const loadDrivers = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });
@@ -179,7 +189,6 @@ export function useDrivers() {
updateDriver, updateDriver,
deleteDriver, deleteDriver,
notification, notification,
clearNotification: () => setNotification(null),
reload: () => loadDrivers(page, search), reload: () => loadDrivers(page, search),
}; };
} }

View File

@@ -1,6 +1,6 @@
"use client"; "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 { orderService } from "@/src/services/order.service";
import type { import type {
Order, Order,
@@ -64,11 +64,21 @@ export function useOrders() {
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [notification, setNotification] = useState<ToastNotification | null>(null); 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) => { const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n); 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 ─────────────────────────────────────────────────────── // ── Fetch orders ───────────────────────────────────────────────────────
const loadOrders = useCallback(async (p: number, q: string, status: string) => { const loadOrders = useCallback(async (p: number, q: string, status: string) => {
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { userService } from "@/src/services/user.service"; import { userService } from "@/src/services/user.service";
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
@@ -42,12 +42,22 @@ export function useUsers() {
const [branches, setBranches] = useState<Branch[]>([]); const [branches, setBranches] = useState<Branch[]>([]);
const [notification, setNotification] = useState<ToastNotification | null>(null); 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) => { const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n); 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 // fetch users with pagination and search
const loadUsers = useCallback(async (p: number, q: string) => { const loadUsers = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });
@@ -90,7 +100,7 @@ useEffect(() => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const res = await userService.update(id, data, token); const res = await userService.update(id, data, token);
dispatch({ type: "UPDATE", user: res }); dispatch({ type: "UPDATE", user: res?.data });
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." }); notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
return true; return true;
} catch (err) { } catch (err) {

14
src/types/audit.ts Normal file
View 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
View 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;
}