diff --git a/app/api/auth/set-cookie/route.ts b/app/api/auth/set-cookie/route.ts index 0761a19..666f7e9 100644 --- a/app/api/auth/set-cookie/route.ts +++ b/app/api/auth/set-cookie/route.ts @@ -1,7 +1,4 @@ -// app/api/auth/set-cookie/route.ts -// Receives the JWT from the client immediately after login and stores it -// in an HttpOnly, Secure, SameSite=Strict cookie. -// The client never stores the raw token — it passes through this endpoint once. + import { NextRequest, NextResponse } from "next/server"; diff --git a/app/dashboard/branches/page.tsx b/app/dashboard/branches/page.tsx index c793e3c..eaf3efd 100644 --- a/app/dashboard/branches/page.tsx +++ b/app/dashboard/branches/page.tsx @@ -1,352 +1,66 @@ "use client"; -import { useCallback, useEffect, useReducer, useRef, useState } from "react"; -import { Alert, Spinner } from "@/src/Components/UI"; -import { getStoredToken } from "@/src/lib/auth"; -import { get, post, put, del } from "@/src/services/api"; - -// ── Types ────────────────────────────────────────────────────────────────── - -interface Branch { - id: string; - name: string; - address?: string | null; - phone?: string | null; - createdAt: string; - updatedAt: string; -} - -interface BranchFormData { - name: string; - address: string; - phone: string; -} - -// ── State / Reducer ──────────────────────────────────────────────────────── - -interface State { - branches: Branch[]; - loading: boolean; - total: number; - pages: number; - error: string | null; -} - -type Action = - | { type: "LOAD_START" } - | { type: "LOAD_OK"; branches: Branch[]; total: number; pages: number } - | { type: "LOAD_ERR"; error: string } - | { type: "ADD"; branch: Branch } - | { type: "UPDATE"; branch: Branch } - | { type: "DELETE"; id: string } - | { type: "CLEAR_ERR" }; - -function reducer(s: State, a: Action): State { - switch (a.type) { - case "LOAD_START": return { ...s, loading: true, error: null }; - case "LOAD_OK": return { ...s, loading: false, branches: a.branches, total: a.total, pages: a.pages }; - case "LOAD_ERR": return { ...s, loading: false, error: a.error }; - case "ADD": return { ...s, branches: [a.branch, ...s.branches], total: s.total + 1 }; - case "UPDATE": return { ...s, branches: s.branches.map(b => b.id === a.branch.id ? a.branch : b) }; - case "DELETE": return { ...s, branches: s.branches.filter(b => b.id !== a.id), total: Math.max(0, s.total - 1) }; - case "CLEAR_ERR": return { ...s, error: null }; - default: return s; - } -} - -// ── Branch Form Modal ────────────────────────────────────────────────────── - -function BranchFormModal({ - editBranch, - onClose, - onSubmit, -}: { - editBranch: Branch | null; - onClose: () => void; - onSubmit: (data: BranchFormData, isNew: boolean) => Promise; -}) { - const isNew = editBranch === null; - const [form, setForm] = useState({ - name: editBranch?.name ?? "", - address: editBranch?.address ?? "", - phone: editBranch?.phone ?? "", - }); - const [errors, setErrors] = useState>({}); - const [saving, setSaving] = useState(false); - const [apiErr, setApiErr] = useState(""); - const nameRef = useRef(null); - - useEffect(() => { nameRef.current?.focus(); }, []); - useEffect(() => { - const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; - window.addEventListener("keydown", h); - return () => window.removeEventListener("keydown", h); - }, [onClose]); - - function validate(): boolean { - const e: Partial = {}; - if (!form.name.trim()) e.name = "اسم الفرع مطلوب"; - setErrors(e); - return Object.keys(e).length === 0; - } - - async function handleSubmit(ev: React.FormEvent) { - ev.preventDefault(); - if (!validate()) return; - setSaving(true); - setApiErr(""); - const ok = await onSubmit(form, isNew); - setSaving(false); - if (ok) onClose(); - else setApiErr("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); - } - - const inputBase: React.CSSProperties = { - width: "100%", height: 40, padding: "0 0.75rem", - borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", - background: "var(--color-surface)", fontSize: 13, - color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)", - }; - - return ( -
{ if (e.target === e.currentTarget) onClose(); }} - style={{ - position: "fixed", inset: 0, zIndex: 50, - background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", - display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", - }} - > -
e.stopPropagation()} style={{ - width: "100%", maxWidth: 480, - background: "var(--color-surface)", borderRadius: "var(--radius-2xl)", - border: "1px solid var(--color-border)", boxShadow: "0 24px 64px rgba(0,0,0,.18)", - overflow: "hidden", - }}> -
-
-

- {isNew ? "إضافة فرع" : "تعديل فرع"} -

-

- {isNew ? "فرع جديد" : editBranch?.name} -

-
- -
- -
- {apiErr && setApiErr("")} />} - - - - - - - -
- - -
- -
-
- ); -} - -// ── Delete Modal ─────────────────────────────────────────────────────────── - -function BranchDeleteModal({ branch, deleting, onCancel, onConfirm }: { - branch: Branch; deleting: boolean; onCancel: () => void; onConfirm: () => void; -}) { - useEffect(() => { - const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; - window.addEventListener("keydown", h); - return () => window.removeEventListener("keydown", h); - }, [onCancel]); - - return ( -
{ if (e.target === e.currentTarget) onCancel(); }} - style={{ - position: "fixed", inset: 0, zIndex: 60, - background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", - display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", - }}> -
e.stopPropagation()} style={{ - width: "100%", maxWidth: 420, background: "var(--color-surface)", - borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", - boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem", - display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center", - }}> -
- - - - -
-
-

حذف الفرع

-

- هل أنت متأكد من حذف فرع {branch.name}؟ لا يمكن التراجع عن هذا الإجراء. -

-
-
- - -
-
-
- ); -} - -// ── Toast ────────────────────────────────────────────────────────────────── - -function Toast({ type, message }: { type: "success" | "error"; message: string }) { - const ok = type === "success"; - return ( -
- {ok ? "✓" : "⚠"} - {message} -
- ); -} - -// ── Page ─────────────────────────────────────────────────────────────────── +import { useState } from "react"; +import { Alert, Toast } from "@/src/Components/UI"; +import { BranchTable } from "@/src/Components/Branch/BranchTable"; +import { BranchFormModal } from "@/src/Components/Branch/BranchFormModal"; +import { BranchDetailModal } from "@/src/Components/Branch/BranchDetailModal"; +import { DeleteConfirmModal } from "@/src/Components/Branch/DeleteConfirmModal"; +import { useBranches } from "@/src/hooks/useBranch"; +import type { Branch, BranchFormData } from "@/src/types/branch"; export default function BranchesPage() { - const [state, dispatch] = useReducer(reducer, { - branches: [], loading: true, total: 0, pages: 1, error: null, - }); - const [search, setSearch] = useState(""); - const [page, setPage] = useState(1); - const [formTarget, setFormTarget] = useState(false); + // ── Modal state ───────────────────────────────────────────────────────────── + // false = closed | null = create mode | Branch = edit mode + const [formTarget, setFormTarget] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); - const [notification, setNotification] = useState<{ type: "success" | "error"; message: string } | null>(null); + // ID of branch whose detail modal is open; null = closed + const [viewBranchId, setViewBranchId] = useState(null); + // Local submitting flag shown in DeleteConfirmModal spinner + const [deleting, setDeleting] = useState(false); - const notify = useCallback((n: { type: "success" | "error"; message: string }) => { - setNotification(n); - setTimeout(() => setNotification(null), 4000); - }, []); + // ── Data hook ─────────────────────────────────────────────────────────────── + const { + branches, loading, total, pages, error, + page, search, + setPage, handleSearch, clearError, + createBranch, updateBranch, deleteBranch, + notification, + } = useBranches(); - const loadBranches = useCallback(async (p: number, q: string) => { - dispatch({ type: "LOAD_START" }); - try { - const token = getStoredToken(); - const qs = `?page=${p}&limit=12${q ? `&search=${encodeURIComponent(q)}` : ""}`; - const res = await get<{ data: { data: Branch[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }>(`branches${qs}`, token); - const payload = (res as unknown as { data: { data: Branch[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }).data; - dispatch({ - type: "LOAD_OK", - branches: payload.data ?? [], - total: payload.meta?.total ?? payload.pagination?.total ?? 0, - pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1, - }); - } catch { - dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل الفروع. يرجى المحاولة مجدداً." }); - } - }, []); + // ── Create / Update handler ───────────────────────────────────────────────── + const handleFormSubmit = async (data: BranchFormData, isNew: boolean): Promise => { + if (isNew) return createBranch(data); + // formTarget is Branch when editing + return updateBranch((formTarget as Branch).id, data); + }; - useEffect(() => { loadBranches(page, search); }, [page, search, loadBranches]); - - const handleFormSubmit = useCallback(async (data: BranchFormData, isNew: boolean): Promise => { - try { - const token = getStoredToken(); - if (isNew) { - const res = await post<{ data: Branch }>("branches", data, token); - dispatch({ type: "ADD", branch: (res as unknown as { data: Branch }).data }); - notify({ type: "success", message: "تم إضافة الفرع بنجاح." }); - } else { - const res = await put<{ data: Branch }>(`branches/${(formTarget as Branch).id}`, data, token); - dispatch({ type: "UPDATE", branch: (res as unknown as { data: Branch }).data }); - notify({ type: "success", message: "تم تحديث الفرع بنجاح." }); - } - return true; - } catch (err) { - notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر حفظ الفرع." }); - return false; - } - }, [formTarget, notify]); - - const handleDeleteConfirm = useCallback(async () => { - if (!deleteTarget) return; + // ── Delete handler ────────────────────────────────────────────────────────── + const handleDeleteConfirm = async () => { + if (!deleteTarget || deleting) return; setDeleting(true); - try { - const token = getStoredToken(); - await del(`branches/${deleteTarget.id}`, token); - dispatch({ type: "DELETE", id: deleteTarget.id }); - notify({ type: "success", message: "تم حذف الفرع بنجاح." }); - setDeleteTarget(null); - } catch (err) { - notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر حذف الفرع." }); - } finally { - setDeleting(false); - } - }, [deleteTarget, notify]); - const cardStyle: React.CSSProperties = { - borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", - background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", - }; - - const thStyle: React.CSSProperties = { - padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700, - textTransform: "uppercase", letterSpacing: "0.2em", - color: "var(--color-text-muted)", background: "var(--color-surface-muted)", - borderBottom: "1px solid var(--color-border)", + const ok = await deleteBranch(deleteTarget.id); + + setDeleting(false); + if (ok) setDeleteTarget(null); }; + // ── Render ────────────────────────────────────────────────────────────────── return ( <> - {notification && } + {/* Global success / error toast — fires on create, update, AND delete */} + + {/* Branch detail modal */} + {viewBranchId && ( + setViewBranchId(null)} + /> + )} + + {/* Create / Edit modal */} {formTarget !== false && ( )} + {/* Delete confirmation dialog */} {deleteTarget && ( - setDeleteTarget(null)} + onCancel={() => { + if (!deleting) setDeleteTarget(null); + }} onConfirm={handleDeleteConfirm} /> )}
- {/* Header */} -
+ {/* ── Page header ── */} +

إدارة الفروع

-

الفروع

+

+ الفروع +

- إجمالي {state.total} فرع + إجمالي {total} فرع

-
+ +
+ {/* Search input */}
- { 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)" }} /> + 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, outline: "none", + fontFamily: "var(--font-sans)", + color: "var(--color-text-primary)", + }} + />
- @@ -399,94 +158,22 @@ export default function BranchesPage() {
- {state.error && dispatch({ type: "CLEAR_ERR" })} />} + {/* General load error */} + {error && } - {/* Table */} -
-
- الفرع - العنوان - الهاتف - تاريخ الإنشاء - إجراءات -
- - {state.loading ? ( -
- - جارٍ التحميل… -
- ) : state.branches.length === 0 ? ( -
-

- {search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."} -

- {!search && ( - - )} -
- ) : ( -
    - {state.branches.map((b, i) => ( -
  • -
    -

    {b.name}

    -

    {b.id.slice(0, 8)}…

    -
    - {b.address ?? "—"} - {b.phone ?? "—"} - - {new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} - -
    - - -
    -
  • - ))} -
- )} - - {state.pages > 1 && ( -
- - صفحة {page} من {state.pages} - -
- {[ - { 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 => ( - - ))} -
-
- )} -
+ {/* Branches table */} + setViewBranchId(branch.id)} + onEdit={branch => setFormTarget(branch)} + onDelete={branch => setDeleteTarget(branch)} + onAddFirst={() => setFormTarget(null)} + onPageChange={setPage} + />
); diff --git a/app/dashboard/clients/[clientId]/addresses/page.tsx b/app/dashboard/clients/[clientId]/addresses/page.tsx index 7680948..8334564 100644 --- a/app/dashboard/clients/[clientId]/addresses/page.tsx +++ b/app/dashboard/clients/[clientId]/addresses/page.tsx @@ -1,36 +1,42 @@ +// app/dashboard/clients/[clientId]/addresses/page.tsx +// +// ── Refactor notes ────────────────────────────────────────────────────────── +// • Address cards are no longer clickable / navigable. Removed the `onView` +// prop, the card's onClick handler, and the pointer cursor + hover styles +// that implied the card itself was a link. +// • Each AddressCard now renders full address details inline (street, city, +// state, district, building/unit/additional no., zip code, country, and +// contact person), instead of a 3-line summary that deferred to the +// AddressDetail page. +// • Removed all routing to the address detail route. +// • Deleted as part of this refactor (no longer referenced anywhere): +// - app/dashboard/clients/[clientId]/addresses/[addressId]/page.tsx +// - app/dashboard/clients/[clientId]/addresses/[addressesId]/page.tsx +// - src/Components/Client_Adress/AddressDetails.tsx +// ───────────────────────────────────────────────────────────────────────── + "use client"; - - import { useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; -// ── UI components (canonical barrel — no old Client/Toast) ───────────────── import { Alert, Toast } from "@/src/Components/UI"; -// ── Hooks & services ─────────────────────────────────────────────────────── import { useClientAddresses } from "@/src/hooks/useClientAddresses"; import { clientService } from "@/src/services/client.service"; import { getStoredToken } from "@/src/lib/auth"; -// ── Types ────────────────────────────────────────────────────────────────── +import type { Client, ClientFormData } from "@/src/types/client"; +import type { ClientAddress } from "@/src/types/client_adresses"; + +import { AddressFormModal, DeleteConfirmModal, ClientFormModal } from "@/src/Components/Client"; import type { - Client, - ClientAddress, - ClientAddressFormData, - ClientFormData, -} from "@/src/types/client"; + CreateAddressFormValues, + UpdateAddressFormValues, +} from "@/src/validations/client_address.validator"; -// ── Client-specific modals ───────────────────────────────────────────────── -import { AddressFormModal,DeleteConfirmModal ,ClientFormModal } from "@/src/Components/Client"; -import { CreateAddressFormValues, UpdateAddressFormValues } from "@/src/validations/client_address.validator"; +// ─── Helpers ─────────────────────────────────────────────────────────────── - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** Emoji icon for known address label types */ function labelIcon(label: string): string { const map: Record = { "فوترة": "💳", @@ -47,29 +53,28 @@ function labelIcon(label: string): string { return map[label.toLowerCase()] ?? "📍"; } -// ───────────────────────────────────────────────────────────────────────────── -// AddressCard sub-component -// ───────────────────────────────────────────────────────────────────────────── +// ─── AddressCard ─────────────────────────────────────────────────────────── interface AddressCardProps { - address: ClientAddress; - onEdit: () => void; - onDelete: () => void; - onMakePrimary: () => void; - /** Whether a "set primary" action is pending for this specific card */ - settingPrimary?: boolean; + address: ClientAddress; + onEdit: () => void; + onDelete: () => void; } -/** - * AddressCard — Renders a single address in the grid. - */ -function AddressCard({ - address, - onEdit, - onDelete, - onMakePrimary, - settingPrimary = false, -}: AddressCardProps) { +function AddressCard({ address, onEdit, onDelete }: AddressCardProps) { + const { details, contactPerson } = address; + const { + street, + city, + state, + district, + buildingNo, + unitNo, + additionalNo, + zipCode, + country, + } = details; + return (
{/* Label row */} -
-
- +
+ + + {address.label} + + {address.branchName && ( - {address.label} + {address.branchName} -
- - {/* Primary badge */} + )} {address.isPrimary && ( - ★ رئيسي + أساسي )}
- {/* Address lines */} + {/* Address details — rendered fully in-card */}
-

{address.street}

-

- {address.city}،{" "}{address.state} {address.postalCode} +

+ {street}

-

{address.country}

+ + {district &&

حي {district}

} + +

+ {city} + {state ? `، ${state}` : ""} + {zipCode ? ` ${zipCode}` : ""} +

+ + {(buildingNo || unitNo || additionalNo) && ( +

+ {buildingNo && `مبنى ${buildingNo}`} + {unitNo && ` · وحدة ${unitNo}`} + {additionalNo && ` · رقم إضافي ${additionalNo}`} +

+ )} + +

{country}

+ {/* Contact person — only when present */} + {(contactPerson?.name || contactPerson?.phone) && ( +
+ 👤 + {contactPerson?.name && {contactPerson.name}} + {contactPerson?.phone && ( + + {contactPerson.phone} + + )} +
+ )} + {/* Actions */}
- + تعديل - - {/* - * Set-primary button — only for non-primary addresses. - * Shows a loading indicator while the API call is in-flight. - */} - {!address.isPrimary && ( - - {settingPrimary ? "…" : "تعيين كرئيسي"} - - )} - void; - color: string; - bg: string; - border: string; - style?: React.CSSProperties; + onClick: () => void; + color: string; + bg: string; + border: string; + style?: React.CSSProperties; disabled?: boolean; - children: React.ReactNode; + children: React.ReactNode; }) { return ( - )} - - ))} - - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// ClientEditModalWithAddresses — wraps ClientFormModal + AddressMiniList -// ───────────────────────────────────────────────────────────────────────────── - -/** - * ClientEditModalWithAddresses - */ -interface ClientEditModalWithAddressesProps { - client: Client; - addresses: ClientAddress[]; - onClose: () => void; +interface ClientEditModalProps { + client: Client; + onClose: () => void; onSubmit: (data: ClientFormData, isNew: boolean) => Promise; - onMakePrimary: (id: string) => Promise; } -function ClientEditModalWithAddresses({ - client, - addresses, - onClose, - onSubmit, - onMakePrimary, -}: ClientEditModalWithAddressesProps) { - const [settingId, setSettingId] = useState(null); - - const handleSetPrimary = async (id: string) => { - setSettingId(id); - await onMakePrimary(id); - setSettingId(null); - }; - +function ClientEditModal({ client, onClose, onSubmit }: ClientEditModalProps) { return ( - // We reuse the backdrop and center the compound modal manually
- {/* Left panel: client form */}
e.stopPropagation()} style={{ width: "100%", maxWidth: 520, - display: "flex", - flexDirection: "column", - gap: "1rem", + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", }} > - {/* Render ClientFormModal in a "portal-less" way by embedding its JSX inline */} -
- {/* We mount ClientFormModal as a standalone dialog — it handles its own - form logic. We intercept onClose to close the whole compound modal. */} - -
- - {/* - * Address section — appended below the form card. - * Satisfies: "create a section that displays all available addresses." - */} -
- {/* Section header */} -
-
-

- عناوين العميل -

-

- {addresses.length} عنوان — اضغط "تعيين كرئيسي" لتغيير العنوان الرئيسي -

-
-
- - {/* Address mini-list */} -
- -
-
+
); } -// ───────────────────────────────────────────────────────────────────────────── -// Page component -// ───────────────────────────────────────────────────────────────────────────── +// ─── Page ────────────────────────────────────────────────────────────────── export default function ClientAddressesPage() { const params = useParams(); const router = useRouter(); - // FIX: "id is undefined" 404 bug. - // params.clientId only works if your route folder is named [clientId]. - // If your folder is actually [id], params.clientId is always undefined, - // which becomes the string "undefined" in API URLs like /client/undefined/addresses. - // CHECK YOUR FOLDER NAME and keep only the line that matches it: const clientId = (params?.clientId ?? params?.id) as string | undefined; - // FIX: warn loudly in dev instead of silently calling the API with no id useEffect(() => { if (!clientId) { - console.warn("ClientAddressesPage: clientId is missing from route params. Check your folder name (must match the key used in useParams())."); + console.warn("ClientAddressesPage: clientId missing from route params."); } }, [clientId]); - // ── Parent client meta ─────────────────────────────────────────────────── + // ── Parent client ──────────────────────────────────────────────────────── const [client, setClient] = useState(null); const [clientLoading, setClientLoading] = useState(true); - // Fetch the parent client so we can show their name / details in the header useEffect(() => { if (!clientId) return; setClientLoading(true); @@ -542,56 +346,31 @@ export default function ClientAddressesPage() { .finally(() => setClientLoading(false)); }, [clientId, router]); - // ── Address CRUD hook ──────────────────────────────────────────────────── + // ── Address hook ───────────────────────────────────────────────────────── const { addresses, loading: addrLoading, error, - notification, // { type, message } — matches ToastNotification + notification, clearError, createAddress, updateAddress, deleteAddress, - makePrimary, reload, } = useClientAddresses(clientId ?? ""); - // NOTE: hook itself guards "if (!clientId) return" before calling the API, - // so passing "" here is safe — it just won't fetch until clientId is real. // ── Modal state ────────────────────────────────────────────────────────── - // Address form: false = closed | null = create | ClientAddress = edit - const [addrFormTarget, setAddrFormTarget] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); - - // Client edit modal (with address section) - const [editingClient, setEditingClient] = useState(false); - - // Per-card primary-setting loading state (card id) - const [settingPrimaryId, setSettingPrimaryId] = useState(null); + const [addrFormTarget, setAddrFormTarget] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [editingClient, setEditingClient] = useState(false); // ── Address form handler ───────────────────────────────────────────────── - // FIX: "addrFormTarget!.id" used "!" which only blocks null/undefined, - // NOT "false" — but addrFormTarget's type is "ClientAddress | null | false". - // If addrFormTarget was ever false here, ".id" would be undefined at runtime, - // causing the "id is undefined" 404 bug from the API call. - // We now check addrFormTarget directly instead of trusting the isNew param, - // so create vs update can never disagree with each other. const handleAddressSubmit = async ( data: CreateAddressFormValues | UpdateAddressFormValues ): Promise => { - if (addrFormTarget === null) { - // create mode - return createAddress(data as CreateAddressFormValues); - } - - if (addrFormTarget === false) { - // modal isn't even open — this should never happen, but guard anyway - console.error("handleAddressSubmit called while addrFormTarget is false (modal closed)"); - return false; - } - - // update mode — addrFormTarget is a real ClientAddress here, .id is safe + if (addrFormTarget === null) return createAddress(data as CreateAddressFormValues); + if (addrFormTarget === false) return false; return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues); }; @@ -604,45 +383,25 @@ export default function ClientAddressesPage() { if (ok) setDeleteTarget(null); }; - // ── Set-primary handler ────────────────────────────────────────────────── - /** - * makePrimaryForCard — wraps makePrimary from the hook with per-card - * loading state so each card can show its own spinner independently. - */ - const makePrimaryForCard = async (id: string): Promise => { - setSettingPrimaryId(id); - const ok = await makePrimary(id); - setSettingPrimaryId(null); - return ok; - }; - - // ── Client edit submit ─────────────────────────────────────────────────── + // ── Client edit handler ────────────────────────────────────────────────── const handleClientEditSubmit = async ( data: ClientFormData, - _isNew: boolean, + _isNew: boolean ): Promise => { if (!client) return false; try { - const token = getStoredToken(); - const res = await clientService.update(client.id, data, token); - setClient(res.data); // optimistically update local state + const res = await clientService.update(client.id, data, getStoredToken()); + setClient(res.data); return true; } catch { return false; } }; - // ── Guard: wait for client ─────────────────────────────────────────────── + // ── Loading guard ──────────────────────────────────────────────────────── if (!clientId || clientLoading) { return ( -
+
- {/* - * Global toast — sourced from UI barrel (not the old Client/Toast). - * The notification object from useClientAddresses has the same shape - * as ToastNotification: { type: "success" | "error"; message: string }. - */} - {/* ── Address create / edit modal ── */} + {/* Address create / edit modal */} {addrFormTarget !== false && ( )} - {/* - * ── Address delete confirmation ── - * We cast the address to the Client shape that DeleteConfirmModal expects - * (it only uses id + name fields from Client). - */} + {/* Delete confirmation */} {deleteTarget && ( )} - {/* - * ── Client edit modal with embedded address section ── - * TEST: Click "تعديل بيانات العميل" in the header. - * → Modal shows client form + address list with "تعيين كرئيسي" buttons. - */} + {/* Client edit modal */} {editingClient && client && ( - setEditingClient(false)} onSubmit={handleClientEditSubmit} - onMakePrimary={makePrimaryForCard} /> )}
- {/* ── Page header ── */} + {/* ── Header ── */}
- {/* Back link */} -

+

إدارة العناوين

-

+

{client ? `${client.name} — العناوين` : "العناوين"}

-

+

إجمالي{" "} - - {addresses.length} - {" "} + {addresses.length}{" "} عنوان

- {/* Header action buttons */}
- {/* Edit client (opens compound modal with address section) */} {client && ( )} - {/* Add address button */}
- {/* - * Error alert — shown when the address load fails. - * Uses the UI/Alert component with an onClose dismiss handler. - * TEST: Simulate a network error → alert banner appears above the grid. - */} - {error && ( - - )} + {error && } {/* ── Address grid ── */} {addrLoading ? ( - /* Loading skeleton-ish state */ -
+
) : addresses.length === 0 ? ( - /* Empty state */

📍

-

+

لا توجد عناوين بعد

-

+

أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات والفواتير.

) : ( - /* - * Address cards grid. - * TEST: - * - Click "تعديل" → AddressFormModal opens pre-filled. - * - Click "تعيين كرئيسي" → button shows "…" loading, then card re-renders - * with "★ رئيسي" badge and blue ring; previously primary card loses the badge. - * - Click "حذف" → DeleteConfirmModal opens → confirm → card removed, toast shown. - */
setAddrFormTarget(addr)} onDelete={() => setDeleteTarget(addr)} - onMakePrimary={() => makePrimaryForCard(addr.id)} - settingPrimary={settingPrimaryId === addr.id} /> ))}
diff --git a/app/dashboard/clients/page.tsx b/app/dashboard/clients/page.tsx index f2f1ac4..e32dcfb 100644 --- a/app/dashboard/clients/page.tsx +++ b/app/dashboard/clients/page.tsx @@ -1,23 +1,5 @@ "use client"; -/** - * app/dashboard/clients/page.tsx — Enhanced - * - * Changes from original: - * 1. Replaced `import { Toast } from "@/src/Components/Client/Toast"` with - * `import { Toast } from "@/src/Components/UI"` (the canonical, feature-rich version - * that supports an optional dismiss button and proper exit animation). - * 2. The notification type is now `ToastNotification` from the UI barrel, matching - * the Toast's expected prop type. - * 3. All other logic (CRUD, routing, modal management) is unchanged. - * - * HOW TO TEST: - * - Add a client → green toast "تم إضافة العميل بنجاح." - * - Edit a client → green toast "تم تحديث بيانات العميل." - * - Delete a client → green toast "تم حذف العميل بنجاح." - * - Click a table row → detail panel expands inline. - * - Click "العناوين" inside the panel → navigates to /dashboard/clients/[id]/addresses. - */ import { useState } from "react"; import { useRouter } from "next/navigation"; diff --git a/app/dashboard/role_permission/page.tsx b/app/dashboard/role_permission/page.tsx index 2aec686..cb2be10 100644 --- a/app/dashboard/role_permission/page.tsx +++ b/app/dashboard/role_permission/page.tsx @@ -1,238 +1,7 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { Alert, Spinner } from "@/src/Components/UI"; -import { RoleToast } from "@/src/Components/role/RoleToast"; -import { useRoles } from "@/src/hooks/useRole"; -import type { Role, Permission } from "@/src/services/role.service"; - -// ── Module label map ─────────────────────────────────────────────────────── -const MODULE_LABELS: Record = { - Role: "الأدوار", User: "المستخدمون", Branch: "الفروع", - Car: "المركبات", CarImage: "صور المركبات", Driver: "السائقون", - Order: "الطلبات", Trip: "الرحلات", Client: "العملاء", - Permission: "الصلاحيات", Audit: "سجل التدقيق", - Dashboard: "لوحة التحكم", Maintenance: "الصيانة", -}; -const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod; - -function groupByModule(permissions: Permission[]): Record { - return permissions.reduce>((acc, p) => { - (acc[p.module || "أخرى"] ??= []).push(p); - return acc; - }, {}); -} - -// ── Permission Matrix ────────────────────────────────────────────────────── - -function PermissionMatrix({ - role, - allPermissions, - onSave, -}: { - role: Role; - allPermissions: Permission[]; - onSave: (roleId: string, permIds: string[]) => Promise; -}) { - const currentIds = role.permissions?.map(rp => rp.permission.id) ?? []; - const [selected, setSelected] = useState>(new Set(currentIds)); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - - const grouped = groupByModule(allPermissions); - - const toggle = (id: string) => { - setSelected(prev => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - setSaved(false); - }; - - const toggleModule = (mod: string) => { - const ids = (grouped[mod] ?? []).map(p => p.id); - const allChecked = ids.every(id => selected.has(id)); - setSelected(prev => { - const next = new Set(prev); - allChecked ? ids.forEach(id => next.delete(id)) : ids.forEach(id => next.add(id)); - return next; - }); - setSaved(false); - }; - - const handleSave = async () => { - setSaving(true); - const ok = await onSave(role.id, [...selected]); - setSaving(false); - if (ok) setSaved(true); - }; +import React from 'react' +export default function page() { return ( -
- {/* Role header */} -
-
-

{role.name}

- {role.description && ( -

{role.description}

- )} -

- {selected.size} من {allPermissions.length} صلاحية مفعّلة -

-
-
- {saved && ( - ✓ تم الحفظ - )} - -
-
- - {/* Module rows */} -
- {Object.entries(grouped).map(([mod, perms]) => { - const allChecked = perms.every(p => selected.has(p.id)); - const someChecked = !allChecked && perms.some(p => selected.has(p.id)); - return ( -
- {/* Module header */} -
- { if (el) el.indeterminate = someChecked; }} - onChange={() => toggleModule(mod)} - style={{ width: 15, height: 15, cursor: "pointer", accentColor: "#2563EB" }} /> - - {moduleLabel(mod)} - - - {perms.filter(p => selected.has(p.id)).length}/{perms.length} - -
- {/* Permissions */} -
- {perms.map(perm => { - const checked = selected.has(perm.id); - return ( - - ); - })} -
-
- ); - })} -
-
- ); +
page
+ ) } - -// ── Page ─────────────────────────────────────────────────────────────────── - -export default function RolePermissionPage() { - const { roles, loading, error, permissions, clearError, updateRolePermissionsBulk, notification } = useRoles(); - const [selectedRoleId, setSelectedRoleId] = useState(""); - - const selectedRole = roles.find(r => r.id === selectedRoleId) ?? null; - - const handleSave = useCallback(async (roleId: string, permIds: string[]): Promise => { - return updateRolePermissionsBulk(roleId, permIds); - }, [updateRolePermissionsBulk]); - - return ( - <> - - -
- - {/* Header */} -
-

- إدارة الصلاحيات -

-
-
-

- مصفوفة الصلاحيات -

-

- إدارة صلاحيات كل دور بشكل مجمَّع -

-
-
- -
-
-
- - {error && } - - {loading ? ( -
- - جارٍ التحميل… -
- ) : !selectedRole ? ( -
-

🔐

-

- اختر دوراً من القائمة أعلاه -

-

- ستظهر هنا صلاحيات الدور المحدد وبإمكانك تعديلها مباشرة. -

-
- ) : ( - - )} -
- - ); -} \ No newline at end of file diff --git a/app/dashboard/roles/page.tsx b/app/dashboard/roles/page.tsx index 3536198..b22af54 100644 --- a/app/dashboard/roles/page.tsx +++ b/app/dashboard/roles/page.tsx @@ -6,37 +6,36 @@ import { RoleTable } from "@/src/Components/role/RoleTable"; import { RoleFormModal } from "@/src/Components/role/RoleFormModal"; import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal"; import { DeleteRoleModal } from "@/src/Components/role/DeleteRoleModal"; -import { RoleToast } from "@/src/Components/role/RoleToast"; +import { Toast } from "@/src/Components/UI"; import { useRoles } from "@/src/hooks/useRole"; -import type { Role, RoleFormData } from "@/src/services/role.service"; +import { Role, RoleFormData } from "@/src/types/role"; export default function RolesPage() { + // ── Modal state ────────────────────────────────────────────────────────────── + // false = closed | null = create | Role = edit + const [formTarget, setFormTarget] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [viewRoleId, setViewRoleId] = useState(null); + const [deleting, setDeleting] = useState(false); + + // ── Data hook ──────────────────────────────────────────────────────────────── const { roles, loading, total, pages, error, permissions, page, search, setPage, handleSearch, clearError, createRole, updateRole, deleteRole, - updateRolePermissionsBulk, notification, } = useRoles(); - // false = closed | null = create mode | Role = edit mode - const [formTarget, setFormTarget] = useState(false); - const [viewTarget, setViewTarget] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); - - const handleFormSubmit = async ( - data: RoleFormData, - currentPermIds: string[], - ): Promise => { - if (formTarget === null) { - return createRole(data); - } + // ── Create / Update ────────────────────────────────────────────────────────── + const handleFormSubmit = async (data: RoleFormData, isNew: boolean): Promise => { + if (isNew) return createRole(data); + const currentPermIds = (formTarget as Role)?.permissions?.map(p => p.permission.id) ?? []; return updateRole((formTarget as Role).id, data, currentPermIds); }; + // ── Delete ─────────────────────────────────────────────────────────────────── const handleDeleteConfirm = async () => { if (!deleteTarget || deleting) return; setDeleting(true); @@ -45,17 +44,21 @@ export default function RolesPage() { if (ok) setDeleteTarget(null); }; - const handleSavePermissions = async ( - roleId: string, - permissionIds: string[], - ): Promise => { - return updateRolePermissionsBulk(roleId, permissionIds); - }; - + // ── Render ─────────────────────────────────────────────────────────────────── return ( <> - + {/* Toast notifications */} + + {/* Detail modal */} + {viewRoleId && ( + setViewRoleId(null)} + /> + )} + + {/* Create / Edit modal */} {formTarget !== false && ( )} - {viewTarget && ( - setViewTarget(null)} - onSave={handleSavePermissions} - /> - )} - + {/* Delete confirmation */} {deleteTarget && ( setDeleteTarget(null)} + onCancel={() => { if (!deleting) setDeleteTarget(null); }} onConfirm={handleDeleteConfirm} /> )}
- {/* Header */} + {/* Page header */}

إدارة الصلاحيات @@ -96,26 +94,59 @@ export default function RolesPage() {

- الأدوار والصلاحيات + الأدوار

إجمالي {total} دور

-
-
+ +
+ {/* Search */} +
- 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)" }} /> + 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, outline: "none", + fontFamily: "var(--font-sans)", + color: "var(--color-text-primary)", + }} + />
- @@ -123,15 +154,17 @@ export default function RolesPage() {
+ {/* Error alert */} {error && } + {/* Roles table */} setViewTarget(role)} + onView={role => setViewRoleId(role.id)} onEdit={role => setFormTarget(role)} onDelete={role => setDeleteTarget(role)} onAddFirst={() => setFormTarget(null)} diff --git a/src/Components/Branch/BranchDetailModal.tsx b/src/Components/Branch/BranchDetailModal.tsx new file mode 100644 index 0000000..bf81ddd --- /dev/null +++ b/src/Components/Branch/BranchDetailModal.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Spinner } from "../UI"; +import { getStoredToken } from "@/src/lib/auth"; +import { branchService } from "@/src/services/branch.service"; +import type { BranchDetail } from "@/src/types/branch"; + +interface BranchDetailModalProps { + branchId: string; + onClose: () => void; +} + +// ── small helper components ─────────────────────────────────────────────────── +function DetailRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ + {label} + + + {value || "—"} + +
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +function BranchIcon() { + return ( +
+ + + + +
+ ); +} + +// ── main component ──────────────────────────────────────────────────────────── +export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps) { + const [branch, setBranch] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + // fetch branch details on mount + useEffect(() => { + let cancelled = false; + (async () => { + try { + const token = getStoredToken(); + const res = await branchService.getById(branchId, token); + if (!cancelled) setBranch(res.data); + } catch { + if (!cancelled) setError("تعذّر تحميل بيانات الفرع. يرجى المحاولة لاحقاً."); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [branchId]); + + // ── helpers ─────────────────────────────────────────────────────────────── + const fmt = (iso?: string | null) => + iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; + + const fullAddress = (b: BranchDetail) => + [b.street, b.district, b.city, b.state, b.country].filter(Boolean).join("، ") || null; + + // ── render ──────────────────────────────────────────────────────────────── + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 55, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 480, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > + {/* ── header ── */} +
+
+

+ بيانات الفرع +

+

+ {branch?.name ?? "عرض الفرع"} +

+
+ +
+ + {/* ── body ── */} +
+ + {/* loading */} + {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {/* error */} + {!loading && error && ( +
+ {error} +
+ )} + + {/* content */} + {!loading && branch && ( +
+ + {/* icon + name + status row */} +
+ +
+

{branch.name}

+ {branch.city && ( +

+ {branch.city} +

+ )} +
+ +
+
+
+ + {/* detail rows */} + + + + + + + + + +
+ )} +
+ + {/* ── footer ── */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Branch/BranchFormModal.tsx b/src/Components/Branch/BranchFormModal.tsx new file mode 100644 index 0000000..a48116f --- /dev/null +++ b/src/Components/Branch/BranchFormModal.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import * as yup from "yup"; +import { Alert, Spinner } from "../UI"; +import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator"; +import type { Branch, BranchFormData, FormErrors } from "@/src/types/branch"; + +// ── fixed styles ───────────────────────────────────────── +const S = { + input: { + width: "100%", height: 40, padding: "0 0.75rem", + borderRadius: "var(--radius-md)", + border: "1px solid var(--color-border)", + background: "var(--color-surface)", + fontSize: 13, color: "var(--color-text-primary)", + outline: "none", fontFamily: "var(--font-sans)", + } as React.CSSProperties, + label: { + display: "flex", flexDirection: "column" as const, + gap: 6, fontSize: 12, fontWeight: 600, + color: "var(--color-text-secondary)", + } as React.CSSProperties, + errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties, +}; + +// ── yup validation ──────────────────────────────────────────────────────────── +async function validate(data: BranchFormData, isNew: boolean): Promise { + const schema = isNew ? createBranchSchema : updateBranchSchema; + try { + await schema.validate(data, { abortEarly: false }); + return {}; + } catch (err) { + if (err instanceof yup.ValidationError) { + return err.inner.reduce((acc, e) => { + const field = e.path as keyof FormErrors; + if (field && !acc[field]) acc[field] = e.message; + return acc; + }, {}); + } + return {}; + } +} + +// ── Props ───────────────────────────────────────────────────────────────────── +interface BranchFormModalProps { + editBranch: Branch | null; + onClose: () => void; + onSubmit: (data: BranchFormData, isNew: boolean) => Promise; +} + +// ── main component ──────────────────────────────────────────────────────────── +export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) { + const isNew = editBranch === null; + + const [form, setForm] = useState({ + name: editBranch?.name ?? "", + email: editBranch?.email ?? "", + phone: editBranch?.phone ?? "", + country: editBranch?.country ?? "SA", + city: editBranch?.city ?? "", + state: editBranch?.state ?? "", + district: editBranch?.district ?? "", + street: editBranch?.street ?? "", + buildingNo: editBranch?.buildingNo ?? "", + unitNo: editBranch?.unitNo ?? "", + zipCode: editBranch?.zipCode ?? "", + latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "", + longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "", + }); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [apiError, setApiError] = useState(""); + const firstInputRef = useRef(null); + + useEffect(() => { firstInputRef.current?.focus(); }, []); + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + const set = (field: keyof BranchFormData) => + (e: React.ChangeEvent) => { + setForm(p => ({ ...p, [field]: e.target.value })); + // clear field error on change + if (errors[field]) setErrors(p => ({ ...p, [field]: undefined })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const errs = await validate(form, isNew); + if (Object.keys(errs).length) { setErrors(errs); return; } + setSaving(true); + setApiError(""); + const ok = await onSubmit(form, isNew); + setSaving(false); + if (ok) onClose(); + else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); + }; + + // dynamic styles for inputs with errors + const inputStyle = (field: keyof FormErrors): React.CSSProperties => ({ + ...S.input, + ...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}), + }); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 50, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 560, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + maxHeight: "90vh", + }} + > + {/* header */} +
+
+

+ {isNew ? "إضافة فرع" : "تعديل فرع"} +

+

+ {isNew ? "فرع جديد" : editBranch?.name} +

+
+ +
+ + {/* body */} +
+ {apiError && setApiError("")} />} + + {/* name */} + + + {/* email + phone */} +
+ + +
+ + {/* city + street */} +
+ + +
+ + {/* state + district */} +
+ + +
+ + {/* buildingNo + unitNo + zipCode */} +
+ + + +
+ + {/* country */} + + + {/* latitude + longitude */} +
+ + +
+ + {/* actions */} +
+ + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Branch/BranchTable.tsx b/src/Components/Branch/BranchTable.tsx new file mode 100644 index 0000000..b6514c7 --- /dev/null +++ b/src/Components/Branch/BranchTable.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { Spinner } from "../UI"; +import type { Branch } from "@/src/types/branch"; + +// ── status badge ───────────────────────────────────────────────────────────── +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +// ── icon button ────────────────────────────────────────────────────────────── +function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { + return ( + + ); +} + +// ── card / header styles ───────────────────────────────────────────────────── +const cardStyle: React.CSSProperties = { + borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", + background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", +}; +const thStyle: React.CSSProperties = { + padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700, + textTransform: "uppercase", letterSpacing: "0.2em", + color: "var(--color-text-muted)", background: "var(--color-surface-muted)", + borderBottom: "1px solid var(--color-border)", +}; + +// ── Props ──────────────────────────────────────────────────────────────────── +interface BranchTableProps { + branches: Branch[]; + loading: boolean; + search: string; + page: number; + pages: number; + onEdit: (branch: Branch) => void; + onDelete: (branch: Branch) => void; + onView: (branch: Branch) => void; + onAddFirst: () => void; + onPageChange: (p: number) => void; +} + +// ── main table ─────────────────────────────────────────────────────────────── +export function BranchTable({ branches, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: BranchTableProps) { + return ( +
+ {/* column headers */} +
+ الفرع + المدينة + الهاتف + الحالة + تاريخ الإنشاء + إجراءات +
+ + {/* loading state */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : branches.length === 0 ? ( + /* empty state */ +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."} +

+ {!search && ( + + )} +
+ ) : ( + /* data rows */ +
    + {branches.map((b, i) => ( +
  • +
    +

    {b.name}

    +

    {b.street}

    +
    + {b.city || "—"} + {b.phone ?? "—"} + + + {new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + +
    + {/* view */} + onView(b)}> + + + + + + {/* edit */} + onEdit(b)}> + + + + + + {/* delete */} + onDelete(b)}> + + + + + +
    +
  • + ))} +
+ )} + + {/* pagination */} + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/Branch/DeleteConfirmModal.tsx b/src/Components/Branch/DeleteConfirmModal.tsx new file mode 100644 index 0000000..22dc4f9 --- /dev/null +++ b/src/Components/Branch/DeleteConfirmModal.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect } from "react"; +import { Spinner } from "../UI"; +import type { Branch } from "@/src/types/branch"; + +interface DeleteConfirmModalProps { + branch: Branch; + deleting: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function DeleteConfirmModal({ branch, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onCancel]); + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }} + > +
e.stopPropagation()} + style={{ width: "100%", maxWidth: 400, background: "var(--color-surface)", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center" }} + > + {/* icon delete */} +
+ + + + +
+
+

حذف الفرع

+

+ هل أنت متأكد من حذف فرع {branch.name}؟ لا يمكن التراجع عن هذا الإجراء. +

+
+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Branch/index.ts b/src/Components/Branch/index.ts new file mode 100644 index 0000000..c2cad60 --- /dev/null +++ b/src/Components/Branch/index.ts @@ -0,0 +1,4 @@ +export { BranchTable } from "./BranchTable"; +export { BranchFormModal } from "./BranchFormModal"; +export { BranchDetailModal } from "./BranchDetailModal"; +export { DeleteConfirmModal } from "./DeleteConfirmModal"; \ No newline at end of file diff --git a/src/Components/Client/Clientformmodal.tsx b/src/Components/Client/Clientformmodal.tsx index bb60ede..b903e17 100644 --- a/src/Components/Client/Clientformmodal.tsx +++ b/src/Components/Client/Clientformmodal.tsx @@ -12,6 +12,7 @@ import { type UpdateClientFormValues, } from "@/src/validations/client.validator"; import type { Client } from "@/src/types/client"; +import { Schema } from "yup"; // ── Styles ───────────────────────────────────────────────────────────────── const S = { @@ -70,7 +71,7 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod setError, formState: { errors, isSubmitting }, } = useForm({ - resolver: yupResolver(schema) as never, // FIX 1 applied here + resolver: yupResolver(Schema) as never, // FIX 1 applied here defaultValues: { name: editClient?.name ?? "", email: editClient?.email ?? "", diff --git a/src/Components/Client/Clienttable.tsx b/src/Components/Client/Clienttable.tsx index b924a76..03e55b0 100644 --- a/src/Components/Client/Clienttable.tsx +++ b/src/Components/Client/Clienttable.tsx @@ -1,15 +1,5 @@ "use client"; -/** - * ClientTable — Enhanced version - * - * Changes from original: - * 1. Clicking a row now opens an inline ClientDetailPanel (slide-down) showing full client info. - * 2. "Addresses" link inside the detail panel navigates to /dashboard/clients/[clientId]/addresses. - * 3. The row-level onClick still calls onManageAddresses (kept for backwards-compatibility), - * but the primary UX is now the expandable detail panel. - * 4. Active row is highlighted with a brand-tinted left border. - */ import { useState } from "react"; import { Spinner } from "../UI"; diff --git a/src/Components/Client/index.ts b/src/Components/Client/index.ts index 5066744..dbac834 100644 --- a/src/Components/Client/index.ts +++ b/src/Components/Client/index.ts @@ -1,18 +1,7 @@ -/** - * src/Components/Client/index.ts - * - * Barrel exports for all Client-domain components. - * - * NOTE: Toast is re-exported here for backwards-compatibility, but it now - * delegates to src/Components/UI/Toast. Prefer importing Toast directly - * from "@/src/Components/UI" in new code. - */ + export { ClientFormModal } from "./Clientformmodal"; export { ClientTable } from "./Clienttable"; export { AddressFormModal } from "../Client_Adress/Addressformmodal"; export { DeleteConfirmModal } from "./Deleteconfirmmodal"; - -// Toast: re-exported from Client/Toast, which itself re-exports UI/Toast. -// Maintains backwards-compat for any consumer importing from "@/src/Components/Client". export { Toast } from "./Toast"; \ No newline at end of file diff --git a/src/Components/Client_Adress/AddressDetails.tsx b/src/Components/Client_Adress/AddressDetails.tsx new file mode 100644 index 0000000..db9f641 --- /dev/null +++ b/src/Components/Client_Adress/AddressDetails.tsx @@ -0,0 +1,258 @@ +"use client"; + +import type { ClientAddress } from "@/src/types/client_adresses"; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function labelIcon(label: string): string { + const map: Record = { + "فوترة": "💳", + "شحن": "📦", + "المقر الرئيسي": "🏢", + "فرع": "🏬", + "مستودع": "🏭", + billing: "💳", + shipping: "📦", + "head office": "🏢", + branch: "🏬", + warehouse: "🏭", + }; + return map[label.toLowerCase()] ?? "📍"; +} + +// ─── Sub-components ──────────────────────────────────────────────────────── + +function DetailRow({ + label, + value, + ltr = false, +}: { + label: string; + value?: string | null; + ltr?: boolean; +}) { + if (!value) return null; + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function SectionCard({ + title, + icon, + children, +}: { + title: string; + icon: string; + children: React.ReactNode; +}) { + return ( +
+ {/* Card header */} +
+ +

+ {title} +

+
+ + {/* Card body */} +
+ {children} +
+
+ ); +} + +// ─── Main component ──────────────────────────────────────────────────────── + +interface AddressDetailsProps { + address: ClientAddress; +} + +export function AddressDetails({ address }: AddressDetailsProps) { + const { details, contactPerson, location } = address; + + return ( +
+ {/* Identity card — label + branch */} +
+ + +
+

+ {address.label} +

+ {address.branchName && ( +

+ {address.branchName} +

+ )} +
+
+ + {/* Address details */} + + + + + + + + + + + + + + {/* Contact person — only when present */} + {(contactPerson?.name || contactPerson?.phone) && ( + + + + + )} + + {/* Coordinates */} + {location?.coordinates && ( + + + + + )} + + {/* System metadata */} + + + + +
+ ); +} \ No newline at end of file diff --git a/src/Components/Client_Adress/Addressformmodal.tsx b/src/Components/Client_Adress/Addressformmodal.tsx index d0ef3f0..f7ed4ac 100644 --- a/src/Components/Client_Adress/Addressformmodal.tsx +++ b/src/Components/Client_Adress/Addressformmodal.tsx @@ -10,7 +10,7 @@ import { type CreateAddressFormValues, type UpdateAddressFormValues, } from "@/src/validations/client_address.validator"; -import type { ClientAddress } from "@/src/types/client"; +import type { ClientAddress } from "@/src/types/client_adresses"; // ── Styles ───────────────────────────────────────────────────────────────── const S = { @@ -50,12 +50,9 @@ const S = { } as React.CSSProperties, }; -// FIX 2: Use only the `border` shorthand in both branches — no borderColor/border conflict const withError = (hasError: boolean): React.CSSProperties => ({ ...S.input, - border: hasError - ? "1px solid var(--color-danger)" - : "1px solid var(--color-border)", + border: hasError ? "1px solid var(--color-danger)" : "1px solid var(--color-border)", background: hasError ? "#FEF2F2" : S.input.background, }); @@ -63,39 +60,27 @@ const withError = (hasError: boolean): React.CSSProperties => ({ interface AddressFormModalProps { editAddress: ClientAddress | null; onClose: () => void; - // FIX: removed the "isNew" second argument. The parent (page.tsx) now - // figures out create-vs-update itself from addrFormTarget, so it no longer - // needs us to tell it isNew. This avoids the two values (isNew here vs - // addrFormTarget there) ever disagreeing with each other. - onSubmit: ( - data: CreateAddressFormValues | UpdateAddressFormValues - ) => Promise; + onSubmit: (data: CreateAddressFormValues | UpdateAddressFormValues) => Promise; } // ── Component ────────────────────────────────────────────────────────────── export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressFormModalProps) { const isNew = editAddress === null; - // NOTE: "schema" variable removed — resolver now picks createAddressSchema - // or updateAddressSchema directly inline (see useForm below). - const { register, handleSubmit, setError, formState: { errors, isSubmitting }, } = useForm({ - // FIX: "as never" was hiding all type errors on resolver/errors. - // yupResolver only works with ONE concrete schema type, not a union, - // so we still need a cast — but cast to the real Resolver type instead - // of "never", so formState.errors keeps proper typing. - resolver: (isNew ? yupResolver(createAddressSchema) : yupResolver(updateAddressSchema)) as Resolver< - CreateAddressFormValues | UpdateAddressFormValues - >, + resolver: (isNew + ? yupResolver(createAddressSchema) + : yupResolver(updateAddressSchema) + ) as Resolver, + defaultValues: { label: editAddress?.label ?? "", branchName: editAddress?.branchName ?? "", - isPrimary: editAddress?.isPrimary ?? false, contactPerson: { name: editAddress?.contactPerson?.name ?? "", @@ -121,9 +106,9 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm }, }); - const detailsErr = (errors as never as Record>)?.details ?? {}; - const contactErr = (errors as never as Record>)?.contactPerson ?? {}; - const locationErr = (errors as never as Record>)?.location ?? {}; + const detailsErr = (errors as any)?.details ?? {}; + const contactErr = (errors as any)?.contactPerson ?? {}; + const locationErr = (errors as any)?.location ?? {}; useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; @@ -132,7 +117,7 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm }, [onClose]); const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => { - const ok = await onSubmit(data); // FIX: no more isNew arg, see prop type above + const ok = await onSubmit(data); if (ok) { onClose(); } else { @@ -173,7 +158,7 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm flexDirection: "column", }} > - {/* Header — pinned */} + {/* Header */}
-

+

{isNew ? "إضافة عنوان" : "تعديل عنوان"}

{isNew ? "عنوان جديد" : editAddress?.label}

@@ -237,12 +208,7 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
{errors.label?.type === "manual" && ( {}} /> @@ -251,58 +217,22 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm {/* Address Meta */}

بيانات العنوان

-
- {/* FIX 3: Replaced */} - - - -
+ {/* Address Details */} @@ -316,119 +246,60 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm placeholder="شارع الملك فهد، مبنى 12" dir="rtl" /> - {detailsErr.street && ( - {detailsErr.street.message} - )} + {detailsErr.street && {detailsErr.street.message}}
-
-
-
-
{/* Contact Person */} @@ -437,29 +308,13 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
-
@@ -469,41 +324,17 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
-
{/* Actions */} -
+
- diff --git a/src/Components/role/RoleDetailModal.tsx b/src/Components/role/RoleDetailModal.tsx index bf422f2..8f35253 100644 --- a/src/Components/role/RoleDetailModal.tsx +++ b/src/Components/role/RoleDetailModal.tsx @@ -1,375 +1,200 @@ "use client"; -import { useState } from "react"; -import { Alert, Spinner } from "../UI"; -import type { Role, Permission } from "../../../services/role.service"; +import { useEffect, useState } from "react"; +import { Spinner } from "../UI"; +import { getStoredToken } from "@/src/lib/auth"; +import { roleService } from "@/src/services/role.service"; +import { Role } from "@/src/types/role"; -const MODULE_LABELS: Record = { - Role: "الأدوار", - User: "المستخدمون", - Branch: "الفروع", - Car: "المركبات", - CarImage: "صور المركبات", - Driver: "السائقون", - Order: "الطلبات", - Trip: "الرحلات", - Client: "العملاء", - Permission: "الصلاحيات", - Audit: "سجل التدقيق", - Dashboard: "لوحة التحكم", - Maintenance: "الصيانة", -}; -const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod; - -function groupByModule( - permissions: Permission[], -): Record { - return permissions.reduce>((acc, p) => { - (acc[p.module || "أخرى"] ??= []).push(p); - return acc; - }, {}); -} interface RoleDetailModalProps { - role: Role; - allPermissions: Permission[]; + roleId: string; onClose: () => void; - onSave: (roleId: string, permissionIds: string[]) => Promise; } -export function RoleDetailModal({ - role, - allPermissions, - onClose, - onSave, -}: RoleDetailModalProps) { - const currentIds = role.permissions?.map((rp) => rp.permission.id) ?? []; - const [selected, setSelected] = useState>(new Set(currentIds)); - const [saving, setSaving] = useState(false); - const [apiError, setApiError] = useState(""); - const [saved, setSaved] = useState(false); +function DetailRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ + {label} + + + {value || "—"} + +
+ ); +} - const grouped = groupByModule(allPermissions); - const activeSet = new Set(currentIds); +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} - const toggle = (id: string) => { - setSelected((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - setSaved(false); - }; +export function RoleDetailModal({ roleId, onClose }: RoleDetailModalProps) { + const [role, setRole] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const handleSave = async () => { - setSaving(true); - setApiError(""); - const ok = await onSave(role.id, [...selected]); - setSaving(false); - if (ok) { - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } else { - setApiError("حدث خطأ أثناء حفظ الصلاحيات. حاول مرة أخرى."); - } - }; + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const token = getStoredToken(); + const res = await roleService.getById(roleId, token); + if (!cancelled) setRole((res as unknown as { data: Role }).data); + } catch { + if (!cancelled) setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً."); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [roleId]); + + const fmt = (iso?: string | null) => + iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; return (
{ - if (e.target === e.currentTarget) onClose(); - }} + role="dialog" aria-modal="true" aria-labelledby="role-detail-title" + onClick={e => { if (e.target === e.currentTarget) onClose(); }} style={{ - position: "fixed", - inset: 0, - zIndex: 50, - background: "rgba(15,23,42,0.55)", - backdropFilter: "blur(4px)", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "1rem", + position: "fixed", inset: 0, zIndex: 55, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", }} > -
e.stopPropagation()} - dir="rtl" - style={{ - width: "100%", - maxWidth: 640, - background: "var(--color-surface)", - borderRadius: "var(--radius-2xl)", - border: "1px solid var(--color-border)", - boxShadow: "0 24px 64px rgba(0,0,0,.18)", - overflow: "hidden", - display: "flex", - flexDirection: "column", - maxHeight: "90vh", - }} - > +
e.stopPropagation()} style={{ + width: "100%", maxWidth: 520, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", display: "flex", flexDirection: "column", + }}> {/* Header */} -
+
-

+

تفاصيل الدور

-

- {role.name} +

+ {role?.name ?? "عرض الدور"}

- {role.description && ( -

- {role.description} -

- )}
-
{/* Body */} -
- {apiError && ( - setApiError("")} - /> - )} - {saved && ( - setSaved(false)} - /> +
+ {loading && ( +
+ + جارٍ التحميل… +
)} -

- {selected.size} من {allPermissions.length} صلاحية مفعّلة لهذا الدور -

+ {!loading && error && ( +
+ {error} +
+ )} -
- {Object.entries(grouped).map(([mod, perms], idx) => ( -
-
- {moduleLabel(mod)} + {!loading && role && ( +
+ {/* Role icon + name + status */} +
+
+ 🛡️
-
- {perms.map((perm) => { - const checked = selected.has(perm.id); - const wasActive = activeSet.has(perm.id); - return ( - - ); - })} +
+

{role.name}

+ {role.description && ( +

{role.description}

+ )} +
+ +
- ))} -
+ + + {role.updatedAt && } + + {/* Permissions list */} + {role.permissions && role.permissions.length > 0 && ( +
+

+ الصلاحيات ({role.permissions.length}) +

+
+ {role.permissions.map(({ permission }) => ( + + {permission.name} + + ))} +
+
+ )} + + {(!role.permissions || role.permissions.length === 0) && ( +
+

لا توجد صلاحيات مسندة لهذا الدور

+
+ )} +
+ )}
{/* Footer */} -
- -
); -} +} \ No newline at end of file diff --git a/src/Components/role/RoleFormModal.tsx b/src/Components/role/RoleFormModal.tsx index 13f7536..9d555fe 100644 --- a/src/Components/role/RoleFormModal.tsx +++ b/src/Components/role/RoleFormModal.tsx @@ -1,12 +1,12 @@ "use client"; - import { useEffect, useRef, useState } from "react"; +import * as yup from "yup"; import { Alert, Spinner } from "../UI"; -import type { Role, RoleFormData, Permission } from "../../../services/role.service"; +import { createRoleSchema, updateRoleSchema, type RoleFormErrors } from "@/src/validations/role.validator"; +import { Permission, Role, RoleFormData } from "@/src/types/role"; // ── Styles ──────────────────────────────────────────────────────────────────── - const S = { input: { width: "100%", height: 40, padding: "0 0.75rem", @@ -21,179 +21,137 @@ const S = { gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)", } as React.CSSProperties, - errorText: { - fontSize: 11, color: "var(--color-danger)", fontWeight: 500, - } as React.CSSProperties, + errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties, }; -// ── Module label map (Arabic) ───────────────────────────────────────────────── - -const MODULE_LABELS: Record = { - Role: "الأدوار", - User: "المستخدمون", - Branch: "الفروع", - Car: "المركبات", - CarImage: "صور المركبات", - Driver: "السائقون", - Order: "الطلبات", - Trip: "الرحلات", - Client: "العملاء", - Permission: "الصلاحيات", - Audit: "سجل التدقيق", - Dashboard: "لوحة التحكم", - Maintenance: "الصيانة", -}; - -const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/** Group permissions by their module field */ -function groupByModule(permissions: Permission[]): Record { - return permissions.reduce>((acc, p) => { - const key = p.module || "أخرى"; - if (!acc[key]) acc[key] = []; - acc[key].push(p); - return acc; - }, {}); +// ── Validation ──────────────────────────────────────────────────────────────── +async function validate(data: RoleFormData, isNew: boolean): Promise { + const schema = isNew ? createRoleSchema : updateRoleSchema; + try { + await schema.validate(data, { abortEarly: false }); + return {}; + } catch (err) { + if (err instanceof yup.ValidationError) { + return err.inner.reduce((acc, e) => { + const field = e.path as keyof RoleFormErrors; + if (field && !acc[field]) acc[field] = e.message; + return acc; + }, {}); + } + return {}; + } } -interface FormErrors { - name?: string; -} - -function validate(name: string): FormErrors { - const e: FormErrors = {}; - if (!name.trim()) e.name = "اسم الدور مطلوب"; - else if (name.trim().length < 2) e.name = "الاسم يجب أن يكون حرفين على الأقل"; - return e; -} +// ── Permission checkbox group ───────────────────────────────────────────────── +/*function PermissionGroup({ + module, perms, selected, onToggle, +}: { + module: string; perms: Permission[]; selected: string[]; onToggle: (id: string) => void; +}) { + return ( +
+

+ {module} +

+
+ {perms.map(p => { + const checked = selected.includes(p.id); + return ( + + ); + })} +
+
+ ); +}*/ // ── Props ───────────────────────────────────────────────────────────────────── - interface RoleFormModalProps { - editRole: Role | null; // null = create mode - permissions: Permission[]; // all available permissions - onClose: () => void; - onSubmit: (data: RoleFormData, currentPermIds: string[]) => Promise; + editRole: Role | null; + permissions: Permission[]; + onClose: () => void; + onSubmit: (data: RoleFormData, isNew: boolean) => Promise; } -// ── Component ───────────────────────────────────────────────────────────────── - +// ── Main component ──────────────────────────────────────────────────────────── export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: RoleFormModalProps) { const isNew = editRole === null; - // Current permission IDs attached to the role being edited - const currentPermIds = editRole?.permissions?.map(rp => rp.permission.id) ?? []; + // Group permissions by module + const grouped = permissions.reduce>((acc, p) => { + if (!acc[p.module]) acc[p.module] = []; + acc[p.module].push(p); + return acc; + }, {}); - const [name, setName] = useState(editRole?.name ?? ""); - const [description, setDescription] = useState(editRole?.description ?? ""); - const [selectedPerms, setSelectedPerms] = useState>(new Set(currentPermIds)); - const [errors, setErrors] = useState({}); - const [saving, setSaving] = useState(false); - const [apiError, setApiError] = useState(""); - const [searchPerm, setSearchPerm] = useState(""); - const [expandedModules, setExpandedModules] = useState>(new Set()); - const firstInputRef = useRef(null); + const currentPermIds = editRole?.permissions?.map(p => p.permission.id) ?? []; - const grouped = groupByModule(permissions); + const [form, setForm] = useState({ + name: editRole?.name ?? "", + description: editRole?.description ?? "", + permissionIds: currentPermIds, + }); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [apiError, setApiError] = useState(""); + const firstInputRef = useRef(null); - // Auto-expand modules that have selected permissions or match search + useEffect(() => { firstInputRef.current?.focus(); }, []); useEffect(() => { - if (editRole) { - const modulesWithSelected = new Set(); - permissions.forEach(p => { - if (currentPermIds.includes(p.id)) modulesWithSelected.add(p.module); - }); - setExpandedModules(modulesWithSelected); - } - firstInputRef.current?.focus(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Keyboard: Escape closes - useEffect(() => { - const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; - window.addEventListener("keydown", h); - return () => window.removeEventListener("keydown", h); + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); }, [onClose]); - // ── Checkbox helpers ─────────────────────────────────────────────────────── + const set = (field: keyof Pick) => + (e: React.ChangeEvent) => { + setForm(p => ({ ...p, [field]: e.target.value })); + if (errors[field]) setErrors(p => ({ ...p, [field]: undefined })); + }; const togglePerm = (id: string) => { - setSelectedPerms(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + setForm(p => ({ + ...p, + permissionIds: p.permissionIds.includes(id) + ? p.permissionIds.filter(x => x !== id) + : [...p.permissionIds, id], + })); }; - const toggleModule = (module: string) => { - const modulePerms = (grouped[module] ?? []).map(p => p.id); - const allSelected = modulePerms.every(id => selectedPerms.has(id)); - setSelectedPerms(prev => { - const next = new Set(prev); - if (allSelected) modulePerms.forEach(id => next.delete(id)); - else modulePerms.forEach(id => next.add(id)); - return next; - }); - }; - - const toggleExpandModule = (module: string) => { - setExpandedModules(prev => { - const next = new Set(prev); - if (next.has(module)) next.delete(module); - else next.add(module); - return next; - }); - }; - - const selectAll = () => setSelectedPerms(new Set(permissions.map(p => p.id))); - const clearAll = () => setSelectedPerms(new Set()); - - // ── Submit ───────────────────────────────────────────────────────────────── - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const errs = validate(name); + const errs = await validate(form, isNew); if (Object.keys(errs).length) { setErrors(errs); return; } - setSaving(true); setApiError(""); - const ok = await onSubmit( - { name: name.trim(), description: description.trim(), permissionIds: [...selectedPerms] }, - currentPermIds, - ); + const ok = await onSubmit(form, isNew); setSaving(false); if (ok) onClose(); else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); }; - // ── Filter permissions by search ─────────────────────────────────────────── - - const filteredGrouped = Object.entries(grouped).reduce>((acc, [mod, perms]) => { - if (!searchPerm.trim()) { - acc[mod] = perms; - } else { - const q = searchPerm.trim().toLowerCase(); - const filtered = perms.filter(p => - p.name.toLowerCase().includes(q) || - p.slug.toLowerCase().includes(q) || - p.module.toLowerCase().includes(q) - ); - if (filtered.length) acc[mod] = filtered; - } - return acc; - }, {}); - - const totalSelected = selectedPerms.size; + const inputStyle = (field: keyof RoleFormErrors): React.CSSProperties => ({ + ...S.input, + ...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}), + }); return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ position: "fixed", inset: 0, zIndex: 50, @@ -204,17 +162,16 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
e.stopPropagation()} style={{ - width: "100%", maxWidth: 640, + width: "100%", maxWidth: 600, background: "var(--color-surface)", borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border)", boxShadow: "0 24px 64px rgba(0,0,0,.18)", - overflow: "hidden", - display: "flex", flexDirection: "column", + overflow: "hidden", display: "flex", flexDirection: "column", maxHeight: "90vh", }} > - {/* ── Modal header ── */} + {/* Header */}

- {isNew ? "دور جديد" : editRole.name} + {isNew ? "دور جديد" : editRole?.name}

-
- {/* ── Scrollable body ── */} + {/* Body */} {apiError && setApiError("")} />} - {/* Name field */} -