update client and client adresses pages also create branch pages and role pages and update some pages to be batter

This commit is contained in:
m7amedez5511
2026-06-30 11:18:49 +03:00
parent 0847bd23ee
commit eaae6810bd
49 changed files with 2559 additions and 3001 deletions

View File

@@ -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"; import { NextRequest, NextResponse } from "next/server";

View File

@@ -1,352 +1,66 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import { useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI"; import { Alert, Toast } from "@/src/Components/UI";
import { getStoredToken } from "@/src/lib/auth"; import { BranchTable } from "@/src/Components/Branch/BranchTable";
import { get, post, put, del } from "@/src/services/api"; import { BranchFormModal } from "@/src/Components/Branch/BranchFormModal";
import { BranchDetailModal } from "@/src/Components/Branch/BranchDetailModal";
// ── Types ────────────────────────────────────────────────────────────────── import { DeleteConfirmModal } from "@/src/Components/Branch/DeleteConfirmModal";
import { useBranches } from "@/src/hooks/useBranch";
interface Branch { import type { Branch, BranchFormData } from "@/src/types/branch";
id: string;
name: string;
address?: string | null;
phone?: string | null;
createdAt: string;
updatedAt: string;
}
interface BranchFormData {
name: string;
address: string;
phone: string;
}
// ── State / Reducer ────────────────────────────────────────────────────────
interface State {
branches: Branch[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type Action =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; branches: Branch[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; branch: Branch }
| { type: "UPDATE"; branch: Branch }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };
function reducer(s: State, a: Action): State {
switch (a.type) {
case "LOAD_START": return { ...s, loading: true, error: null };
case "LOAD_OK": return { ...s, loading: false, branches: a.branches, total: a.total, pages: a.pages };
case "LOAD_ERR": return { ...s, loading: false, error: a.error };
case "ADD": return { ...s, branches: [a.branch, ...s.branches], total: s.total + 1 };
case "UPDATE": return { ...s, branches: s.branches.map(b => b.id === a.branch.id ? a.branch : b) };
case "DELETE": return { ...s, branches: s.branches.filter(b => b.id !== a.id), total: Math.max(0, s.total - 1) };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
// ── Branch Form Modal ──────────────────────────────────────────────────────
function BranchFormModal({
editBranch,
onClose,
onSubmit,
}: {
editBranch: Branch | null;
onClose: () => void;
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
}) {
const isNew = editBranch === null;
const [form, setForm] = useState<BranchFormData>({
name: editBranch?.name ?? "",
address: editBranch?.address ?? "",
phone: editBranch?.phone ?? "",
});
const [errors, setErrors] = useState<Partial<BranchFormData>>({});
const [saving, setSaving] = useState(false);
const [apiErr, setApiErr] = useState("");
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { nameRef.current?.focus(); }, []);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
function validate(): boolean {
const e: Partial<BranchFormData> = {};
if (!form.name.trim()) e.name = "اسم الفرع مطلوب";
setErrors(e);
return Object.keys(e).length === 0;
}
async function handleSubmit(ev: React.FormEvent) {
ev.preventDefault();
if (!validate()) return;
setSaving(true);
setApiErr("");
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiErr("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}
const inputBase: React.CSSProperties = {
width: "100%", height: 40, padding: "0 0.75rem",
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", fontSize: 13,
color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)",
};
return (
<div
role="dialog" aria-modal="true"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div onClick={e => e.stopPropagation()} style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)", borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)", boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
}}>
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem", borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
{isNew ? "إضافة فرع" : "تعديل فرع"}
</p>
<h2 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "فرع جديد" : editBranch?.name}
</h2>
</div>
<button type="button" onClick={onClose} style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)", background: "var(--color-surface)",
cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>×</button>
</div>
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{apiErr && <Alert type="error" message={apiErr} onClose={() => setApiErr("")} />}
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
اسم الفرع *
<input ref={nameRef} style={{ ...inputBase, ...(errors.name ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}) }}
value={form.name} onChange={e => { setForm(p => ({ ...p, name: e.target.value })); setErrors(p => ({ ...p, name: undefined })); }}
placeholder="فرع الرياض" dir="rtl" />
{errors.name && <span style={{ fontSize: 11, color: "var(--color-danger)" }}>{errors.name}</span>}
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
العنوان (اختياري)
<input style={inputBase} value={form.address}
onChange={e => setForm(p => ({ ...p, address: e.target.value }))}
placeholder="شارع الملك فهد، الرياض" dir="rtl" />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
رقم الهاتف (اختياري)
<input style={inputBase} value={form.phone}
onChange={e => setForm(p => ({ ...p, phone: e.target.value }))}
placeholder="+966 11 xxx xxxx" dir="ltr" />
</label>
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button type="button" onClick={onClose} disabled={saving}
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="submit" disabled={saving}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة الفرع" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Delete Modal ───────────────────────────────────────────────────────────
function BranchDeleteModal({ branch, deleting, onCancel, onConfirm }: {
branch: Branch; deleting: boolean; onCancel: () => void; onConfirm: () => void;
}) {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onCancel]);
return (
<div role="alertdialog" aria-modal="true"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{
position: "fixed", inset: 0, zIndex: 60,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}>
<div onClick={e => e.stopPropagation()} style={{
width: "100%", maxWidth: 420, background: "var(--color-surface)",
borderRadius: "var(--radius-xl)", border: "1px solid #FECACA",
boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem",
display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center",
}}>
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</div>
<div>
<h2 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف الفرع</h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف فرع <strong style={{ color: "var(--color-text-primary)" }}>{branch.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={onCancel} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
{deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button>
</div>
</div>
</div>
);
}
// ── Toast ──────────────────────────────────────────────────────────────────
function Toast({ type, message }: { type: "success" | "error"; message: string }) {
const ok = type === "success";
return (
<div role="status" style={{
position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)", zIndex: 9999,
display: "flex", alignItems: "center", gap: 10, padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)", background: ok ? "#065F46" : "#7F1D1D",
color: "#FFF", fontSize: 13, fontWeight: 600, boxShadow: "0 8px 32px rgba(0,0,0,.25)",
maxWidth: "90vw", whiteSpace: "nowrap", fontFamily: "var(--font-sans)",
}}>
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
<span>{message}</span>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function BranchesPage() { export default function BranchesPage() {
const [state, dispatch] = useReducer(reducer, { // ── Modal state ─────────────────────────────────────────────────────────────
branches: [], loading: true, total: 0, pages: 1, error: null, // false = closed | null = create mode | Branch = edit mode
}); const [formTarget, setFormTarget] = useState<Branch | null | false>(false);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [formTarget, setFormTarget] = useState<Branch | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null); const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
const [deleting, setDeleting] = useState(false); // ID of branch whose detail modal is open; null = closed
const [notification, setNotification] = useState<{ type: "success" | "error"; message: string } | null>(null); const [viewBranchId, setViewBranchId] = useState<string | null>(null);
// Local submitting flag shown in DeleteConfirmModal spinner
const [deleting, setDeleting] = useState(false);
const notify = useCallback((n: { type: "success" | "error"; message: string }) => { // ── Data hook ───────────────────────────────────────────────────────────────
setNotification(n); const {
setTimeout(() => setNotification(null), 4000); branches, loading, total, pages, error,
}, []); page, search,
setPage, handleSearch, clearError,
createBranch, updateBranch, deleteBranch,
notification,
} = useBranches();
const loadBranches = useCallback(async (p: number, q: string) => { // ── Create / Update handler ─────────────────────────────────────────────────
dispatch({ type: "LOAD_START" }); const handleFormSubmit = async (data: BranchFormData, isNew: boolean): Promise<boolean> => {
try { if (isNew) return createBranch(data);
const token = getStoredToken(); // formTarget is Branch when editing
const qs = `?page=${p}&limit=12${q ? `&search=${encodeURIComponent(q)}` : ""}`; return updateBranch((formTarget as Branch).id, data);
const res = await get<{ data: { data: Branch[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }>(`branches${qs}`, token); };
const payload = (res as unknown as { data: { data: Branch[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }).data;
dispatch({
type: "LOAD_OK",
branches: payload.data ?? [],
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
});
} catch {
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل الفروع. يرجى المحاولة مجدداً." });
}
}, []);
useEffect(() => { loadBranches(page, search); }, [page, search, loadBranches]); // ── Delete handler ──────────────────────────────────────────────────────────
const handleDeleteConfirm = async () => {
const handleFormSubmit = useCallback(async (data: BranchFormData, isNew: boolean): Promise<boolean> => { if (!deleteTarget || deleting) return;
try {
const token = getStoredToken();
if (isNew) {
const res = await post<{ data: Branch }>("branches", data, token);
dispatch({ type: "ADD", branch: (res as unknown as { data: Branch }).data });
notify({ type: "success", message: "تم إضافة الفرع بنجاح." });
} else {
const res = await put<{ data: Branch }>(`branches/${(formTarget as Branch).id}`, data, token);
dispatch({ type: "UPDATE", branch: (res as unknown as { data: Branch }).data });
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
}
return true;
} catch (err) {
notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر حفظ الفرع." });
return false;
}
}, [formTarget, notify]);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setDeleting(true); 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 = { const ok = await deleteBranch(deleteTarget.id);
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", setDeleting(false);
}; if (ok) setDeleteTarget(null);
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)",
}; };
// ── Render ──────────────────────────────────────────────────────────────────
return ( return (
<> <>
{notification && <Toast type={notification.type} message={notification.message} />} {/* Global success / error toast — fires on create, update, AND delete */}
<Toast notification={notification} />
{/* Branch detail modal */}
{viewBranchId && (
<BranchDetailModal
branchId={viewBranchId}
onClose={() => setViewBranchId(null)}
/>
)}
{/* Create / Edit modal */}
{formTarget !== false && ( {formTarget !== false && (
<BranchFormModal <BranchFormModal
editBranch={formTarget} editBranch={formTarget}
@@ -355,43 +69,88 @@ export default function BranchesPage() {
/> />
)} )}
{/* Delete confirmation dialog */}
{deleteTarget && ( {deleteTarget && (
<BranchDeleteModal <DeleteConfirmModal
branch={deleteTarget} branch={deleteTarget}
deleting={deleting} deleting={deleting}
onCancel={() => setDeleteTarget(null)} onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
/> />
)} )}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Header */} {/* ── Page header ── */}
<header style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)" }}> <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 style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الفروع إدارة الفروع
</p> </p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>الفروع</h1> <h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
الفروع
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}> <p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{state.total}</strong> فرع إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> فرع
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
{/* Search input */}
<div style={{ position: "relative", width: 256 }}> <div style={{ position: "relative", width: 256 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" /> <circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg> </svg>
<input type="text" placeholder="بحث بالاسم..." value={search} <input
onChange={e => { setSearch(e.target.value); setPage(1); }} dir="rtl" type="text"
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)" }} /> 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, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div> </div>
<button type="button" onClick={() => setFormTarget(null)}
style={{ height: 40, padding: "0 1.125rem", borderRadius: "var(--radius-lg)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 7, fontFamily: "var(--font-sans)", boxShadow: "0 1px 4px rgba(37,99,235,.35)", whiteSpace: "nowrap" }}> {/* Add branch 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",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
إضافة فرع إضافة فرع
</button> </button>
@@ -399,94 +158,22 @@ export default function BranchesPage() {
</div> </div>
</header> </header>
{state.error && <Alert type="error" message={state.error} onClose={() => dispatch({ type: "CLEAR_ERR" })} />} {/* General load error */}
{error && <Alert type="error" message={error} onClose={clearError} />}
{/* Table */} {/* Branches table */}
<div style={cardStyle}> <BranchTable
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 2fr 1.5fr 1fr 100px", ...thStyle }}> branches={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> onView={branch => setViewBranchId(branch.id)}
</div> onEdit={branch => setFormTarget(branch)}
onDelete={branch => setDeleteTarget(branch)}
{state.loading ? ( onAddFirst={() => setFormTarget(null)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}> onPageChange={setPage}
<Spinner size="sm" className="text-blue-600" /> />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : state.branches.length === 0 ? (
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
</p>
{!search && (
<button type="button" onClick={() => setFormTarget(null)}
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
أضف أول فرع
</button>
)}
</div>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{state.branches.map((b, i) => (
<li key={b.id} style={{
display: "grid", gridTemplateColumns: "2fr 2fr 1.5fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>{b.id.slice(0, 8)}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{b.address ?? "—"}</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>{b.phone ?? "—"}</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 6 }}>
<button type="button" title="تعديل" onClick={() => setFormTarget(b)}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #BFDBFE", background: "#EFF6FF", color: "#1D4ED8", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button type="button" title="حذف" onClick={() => setDeleteTarget(b)}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #FECACA", background: "#FEF2F2", color: "#DC2626", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</div>
</li>
))}
</ul>
)}
{state.pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{state.pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
{ label: "التالي", action: () => setPage(p => Math.min(state.pages, p + 1)), disabled: page === state.pages },
].map(btn => (
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
</section> </section>
</> </>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,5 @@
"use client"; "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 { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";

View File

@@ -1,238 +1,7 @@
"use client"; import React from 'react'
import { useCallback, useEffect, useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI";
import { RoleToast } from "@/src/Components/role/RoleToast";
import { useRoles } from "@/src/hooks/useRole";
import type { Role, Permission } from "@/src/services/role.service";
// ── Module label map ───────────────────────────────────────────────────────
const MODULE_LABELS: Record<string, string> = {
Role: "الأدوار", User: "المستخدمون", Branch: "الفروع",
Car: "المركبات", CarImage: "صور المركبات", Driver: "السائقون",
Order: "الطلبات", Trip: "الرحلات", Client: "العملاء",
Permission: "الصلاحيات", Audit: "سجل التدقيق",
Dashboard: "لوحة التحكم", Maintenance: "الصيانة",
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
function groupByModule(permissions: Permission[]): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
(acc[p.module || "أخرى"] ??= []).push(p);
return acc;
}, {});
}
// ── Permission Matrix ──────────────────────────────────────────────────────
function PermissionMatrix({
role,
allPermissions,
onSave,
}: {
role: Role;
allPermissions: Permission[];
onSave: (roleId: string, permIds: string[]) => Promise<boolean>;
}) {
const currentIds = role.permissions?.map(rp => rp.permission.id) ?? [];
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const grouped = groupByModule(allPermissions);
const toggle = (id: string) => {
setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
setSaved(false);
};
const toggleModule = (mod: string) => {
const ids = (grouped[mod] ?? []).map(p => p.id);
const allChecked = ids.every(id => selected.has(id));
setSelected(prev => {
const next = new Set(prev);
allChecked ? ids.forEach(id => next.delete(id)) : ids.forEach(id => next.add(id));
return next;
});
setSaved(false);
};
const handleSave = async () => {
setSaving(true);
const ok = await onSave(role.id, [...selected]);
setSaving(false);
if (ok) setSaved(true);
};
export default function page() {
return ( return (
<div style={{ <div>page</div>
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", )
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", overflow: "hidden",
}}>
{/* Role header */}
<div style={{
padding: "1rem 1.5rem", borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div>
<p style={{ fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
{role.description && (
<p style={{ fontSize: 12, color: "var(--color-text-muted)", marginTop: 2 }}>{role.description}</p>
)}
<p style={{ fontSize: 11, color: "var(--color-text-hint)", marginTop: 4 }}>
{selected.size} من {allPermissions.length} صلاحية مفعّلة
</p>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{saved && (
<span style={{ fontSize: 12, color: "#166534", fontWeight: 600 }}> تم الحفظ</span>
)}
<button type="button" onClick={handleSave} disabled={saving}
style={{
height: 36, padding: "0 1.25rem", borderRadius: "var(--radius-md)",
border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: saving ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)",
}}>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : "حفظ"}
</button>
</div>
</div>
{/* Module rows */}
<div style={{ padding: "0.75rem" }}>
{Object.entries(grouped).map(([mod, perms]) => {
const allChecked = perms.every(p => selected.has(p.id));
const someChecked = !allChecked && perms.some(p => selected.has(p.id));
return (
<div key={mod} style={{ marginBottom: "0.5rem" }}>
{/* Module header */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.5rem 0.75rem",
background: "var(--color-surface-muted)",
borderRadius: "var(--radius-md)",
marginBottom: "0.375rem",
}}>
<input type="checkbox" checked={allChecked}
ref={el => { if (el) el.indeterminate = someChecked; }}
onChange={() => toggleModule(mod)}
style={{ width: 15, height: 15, cursor: "pointer", accentColor: "#2563EB" }} />
<span style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-primary)" }}>
{moduleLabel(mod)}
</span>
<span style={{ fontSize: 10, color: "var(--color-text-muted)", marginRight: "auto" }}>
{perms.filter(p => selected.has(p.id)).length}/{perms.length}
</span>
</div>
{/* Permissions */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.375rem", paddingRight: "1.5rem" }}>
{perms.map(perm => {
const checked = selected.has(perm.id);
return (
<label key={perm.id} style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "0.3rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked ? "#EFF6FF" : "var(--color-surface-muted)",
cursor: "pointer", userSelect: "none",
}}>
<input type="checkbox" checked={checked} onChange={() => toggle(perm.id)}
style={{ width: 13, height: 13, cursor: "pointer", accentColor: "#2563EB" }} />
<span style={{ fontSize: 11, fontWeight: 600, color: checked ? "#1D4ED8" : "var(--color-text-primary)" }}>
{perm.name}
</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
} }
// ── Page ───────────────────────────────────────────────────────────────────
export default function RolePermissionPage() {
const { roles, loading, error, permissions, clearError, updateRolePermissionsBulk, notification } = useRoles();
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const selectedRole = roles.find(r => r.id === selectedRoleId) ?? null;
const handleSave = useCallback(async (roleId: string, permIds: string[]): Promise<boolean> => {
return updateRolePermissionsBulk(roleId, permIds);
}, [updateRolePermissionsBulk]);
return (
<>
<RoleToast notification={notification} />
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Header */}
<header style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الصلاحيات
</p>
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
مصفوفة الصلاحيات
</h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إدارة صلاحيات كل دور بشكل مجمَّع
</p>
</div>
<div style={{ minWidth: 240 }}>
<select value={selectedRoleId} onChange={e => setSelectedRoleId(e.target.value)} dir="rtl"
style={{ width: "100%", height: 40, padding: "0 0.75rem", borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)" }}>
<option value="">اختر دوراً لتعديل صلاحياته</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
</div>
</header>
{error && <Alert type="error" message={error} onClose={clearError} />}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : !selectedRole ? (
<div style={{
borderRadius: "var(--radius-xl)", border: "2px dashed var(--color-border)",
background: "var(--color-surface)", padding: "4rem 2rem", textAlign: "center",
}}>
<p style={{ fontSize: 32, margin: 0 }}>🔐</p>
<p style={{ marginTop: 12, fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)" }}>
اختر دوراً من القائمة أعلاه
</p>
<p style={{ marginTop: 4, fontSize: 13, color: "var(--color-text-muted)" }}>
ستظهر هنا صلاحيات الدور المحدد وبإمكانك تعديلها مباشرة.
</p>
</div>
) : (
<PermissionMatrix
key={selectedRole.id}
role={selectedRole}
allPermissions={permissions}
onSave={handleSave}
/>
)}
</section>
</>
);
}

View File

@@ -6,37 +6,36 @@ import { RoleTable } from "@/src/Components/role/RoleTable";
import { RoleFormModal } from "@/src/Components/role/RoleFormModal"; import { RoleFormModal } from "@/src/Components/role/RoleFormModal";
import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal"; import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal";
import { DeleteRoleModal } from "@/src/Components/role/DeleteRoleModal"; 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 { 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() { export default function RolesPage() {
// ── Modal state ──────────────────────────────────────────────────────────────
// false = closed | null = create | Role = edit
const [formTarget, setFormTarget] = useState<Role | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null);
const [viewRoleId, setViewRoleId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
// ── Data hook ────────────────────────────────────────────────────────────────
const { const {
roles, loading, total, pages, error, roles, loading, total, pages, error,
permissions, permissions,
page, search, page, search,
setPage, handleSearch, clearError, setPage, handleSearch, clearError,
createRole, updateRole, deleteRole, createRole, updateRole, deleteRole,
updateRolePermissionsBulk,
notification, notification,
} = useRoles(); } = useRoles();
// false = closed | null = create mode | Role = edit mode // ── Create / Update ──────────────────────────────────────────────────────────
const [formTarget, setFormTarget] = useState<Role | null | false>(false); const handleFormSubmit = async (data: RoleFormData, isNew: boolean): Promise<boolean> => {
const [viewTarget, setViewTarget] = useState<Role | null>(null); if (isNew) return createRole(data);
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null); const currentPermIds = (formTarget as Role)?.permissions?.map(p => p.permission.id) ?? [];
const [deleting, setDeleting] = useState(false);
const handleFormSubmit = async (
data: RoleFormData,
currentPermIds: string[],
): Promise<boolean> => {
if (formTarget === null) {
return createRole(data);
}
return updateRole((formTarget as Role).id, data, currentPermIds); return updateRole((formTarget as Role).id, data, currentPermIds);
}; };
// ── Delete ───────────────────────────────────────────────────────────────────
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return; if (!deleteTarget || deleting) return;
setDeleting(true); setDeleting(true);
@@ -45,17 +44,21 @@ export default function RolesPage() {
if (ok) setDeleteTarget(null); if (ok) setDeleteTarget(null);
}; };
const handleSavePermissions = async ( // ── Render ───────────────────────────────────────────────────────────────────
roleId: string,
permissionIds: string[],
): Promise<boolean> => {
return updateRolePermissionsBulk(roleId, permissionIds);
};
return ( return (
<> <>
<RoleToast notification={notification} /> {/* Toast notifications */}
<Toast notification={notification} />
{/* Detail modal */}
{viewRoleId && (
<RoleDetailModal
roleId={viewRoleId}
onClose={() => setViewRoleId(null)}
/>
)}
{/* Create / Edit modal */}
{formTarget !== false && ( {formTarget !== false && (
<RoleFormModal <RoleFormModal
editRole={formTarget} editRole={formTarget}
@@ -65,30 +68,25 @@ export default function RolesPage() {
/> />
)} )}
{viewTarget && ( {/* Delete confirmation */}
<RoleDetailModal
role={viewTarget}
allPermissions={permissions}
onClose={() => setViewTarget(null)}
onSave={handleSavePermissions}
/>
)}
{deleteTarget && ( {deleteTarget && (
<DeleteRoleModal <DeleteRoleModal
role={deleteTarget} role={deleteTarget}
deleting={deleting} deleting={deleting}
onCancel={() => setDeleteTarget(null)} onCancel={() => { if (!deleting) setDeleteTarget(null); }}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
/> />
)} )}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Header */} {/* Page header */}
<header style={{ <header style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-xl)",
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)", 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 style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
إدارة الصلاحيات إدارة الصلاحيات
@@ -96,26 +94,59 @@ export default function RolesPage() {
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}> <h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
الأدوار والصلاحيات الأدوار
</h1> </h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}> <p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> دور إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> دور
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ position: "relative", width: 256 }}> <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
{/* Search */}
<div style={{ position: "relative", width: 240 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} <svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" /> <circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg> </svg>
<input type="text" placeholder="بحث بالاسم…" value={search} <input
onChange={e => handleSearch(e.target.value)} dir="rtl" type="text"
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)" }} /> 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, outline: "none",
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
}}
/>
</div> </div>
<button type="button" onClick={() => setFormTarget(null)}
style={{ height: 40, padding: "0 1.125rem", borderRadius: "var(--radius-lg)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 7, fontFamily: "var(--font-sans)", boxShadow: "0 1px 4px rgba(37,99,235,.35)", whiteSpace: "nowrap" }}> {/* Add role 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",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
إضافة دور إضافة دور
</button> </button>
@@ -123,15 +154,17 @@ export default function RolesPage() {
</div> </div>
</header> </header>
{/* Error alert */}
{error && <Alert type="error" message={error} onClose={clearError} />} {error && <Alert type="error" message={error} onClose={clearError} />}
{/* Roles table */}
<RoleTable <RoleTable
roles={roles} roles={roles}
loading={loading} loading={loading}
search={search} search={search}
page={page} page={page}
pages={pages} pages={pages}
onView={role => setViewTarget(role)} onView={role => setViewRoleId(role.id)}
onEdit={role => setFormTarget(role)} onEdit={role => setFormTarget(role)}
onDelete={role => setDeleteTarget(role)} onDelete={role => setDeleteTarget(role)}
onAddFirst={() => setFormTarget(null)} onAddFirst={() => setFormTarget(null)}

View File

@@ -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 (
<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, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function BranchIcon() {
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)",
}}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
</svg>
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps) {
const [branch, setBranch] = useState<BranchDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div
role="dialog" aria-modal="true" aria-labelledby="branch-detail-title"
onClick={e => { 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",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
بيانات الفرع
</p>
<h2 id="branch-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{branch?.name ?? "عرض الفرع"}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && (
<div style={{
padding: "1rem 1.25rem",
borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, color: "#991B1B", fontWeight: 500,
textAlign: "center",
}}>
{error}
</div>
)}
{/* content */}
{!loading && branch && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* icon + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<BranchIcon />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{branch.name}</p>
{branch.city && (
<p style={{ marginTop: 3, fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{branch.city}
</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={branch.isActive} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={branch.phone} />
<DetailRow label="البريد الإلكتروني" value={branch.email} />
<DetailRow label="العنوان" value={fullAddress(branch)} />
<DetailRow label="رقم المبنى" value={branch.buildingNo} />
<DetailRow label="رقم الوحدة" value={branch.unitNo} />
<DetailRow label="الرمز البريدي" value={branch.zipCode} />
<DetailRow
label="الموقع الجغرافي"
value={branch.latitude != null && branch.longitude != null
? `${branch.latitude}, ${branch.longitude}`
: null}
/>
<DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
</div>
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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<FormErrors> {
const schema = isNew ? createBranchSchema : updateBranchSchema;
try {
await schema.validate(data, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<FormErrors>((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<boolean>;
}
// ── main component ────────────────────────────────────────────────────────────
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
const isNew = editBranch === null;
const [form, setForm] = useState<BranchFormData>({
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<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div
role="dialog" aria-modal="true" aria-labelledby="branch-modal-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",
}}
>
<div
onClick={e => 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 */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
{isNew ? "إضافة فرع" : "تعديل فرع"}
</p>
<h2 id="branch-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "فرع جديد" : editBranch?.name}
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto" }}>
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
{/* name */}
<label style={S.label}>
اسم الفرع *
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="فرع الرياض" autoComplete="organization" dir="rtl" />
{errors.name && <span style={S.errorText}>{errors.name}</span>}
</label>
{/* email + phone */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
البريد الإلكتروني
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="branch@co.sa" autoComplete="email" dir="ltr" />
{errors.email && <span style={S.errorText}>{errors.email}</span>}
</label>
<label style={S.label}>
رقم الهاتف
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" />
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
</label>
</div>
{/* city + street */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
المدينة *
<input style={inputStyle("city")} value={form.city} onChange={set("city")} placeholder="الرياض" dir="rtl" />
{errors.city && <span style={S.errorText}>{errors.city}</span>}
</label>
<label style={S.label}>
الشارع *
<input style={inputStyle("street")} value={form.street} onChange={set("street")} placeholder="شارع الملك فهد" dir="rtl" />
{errors.street && <span style={S.errorText}>{errors.street}</span>}
</label>
</div>
{/* state + district */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
المنطقة
<input style={inputStyle("state")} value={form.state} onChange={set("state")} placeholder="منطقة الرياض" dir="rtl" />
{errors.state && <span style={S.errorText}>{errors.state}</span>}
</label>
<label style={S.label}>
الحي
<input style={inputStyle("district")} value={form.district} onChange={set("district")} placeholder="حي العليا" dir="rtl" />
{errors.district && <span style={S.errorText}>{errors.district}</span>}
</label>
</div>
{/* buildingNo + unitNo + zipCode */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
رقم المبنى
<input style={inputStyle("buildingNo")} value={form.buildingNo} onChange={set("buildingNo")} placeholder="1234" dir="ltr" />
{errors.buildingNo && <span style={S.errorText}>{errors.buildingNo}</span>}
</label>
<label style={S.label}>
رقم الوحدة
<input style={inputStyle("unitNo")} value={form.unitNo} onChange={set("unitNo")} placeholder="5" dir="ltr" />
{errors.unitNo && <span style={S.errorText}>{errors.unitNo}</span>}
</label>
<label style={S.label}>
الرمز البريدي
<input style={inputStyle("zipCode")} value={form.zipCode} onChange={set("zipCode")} placeholder="12345" dir="ltr" />
{errors.zipCode && <span style={S.errorText}>{errors.zipCode}</span>}
</label>
</div>
{/* country */}
<label style={S.label}>
الدولة
<input style={inputStyle("country")} value={form.country} onChange={set("country")} placeholder="SA" dir="ltr" />
{errors.country && <span style={S.errorText}>{errors.country}</span>}
</label>
{/* latitude + longitude */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
خط العرض (اختياري)
<input style={inputStyle("latitude")} value={form.latitude} onChange={set("latitude")} placeholder="24.7136" dir="ltr" inputMode="decimal" />
{errors.latitude && <span style={S.errorText}>{errors.latitude}</span>}
</label>
<label style={S.label}>
خط الطول (اختياري)
<input style={inputStyle("longitude")} value={form.longitude} onChange={set("longitude")} placeholder="46.6753" dir="ltr" inputMode="decimal" />
{errors.longitude && <span style={S.errorText}>{errors.longitude}</span>}
</label>
</div>
{/* actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button type="button" onClick={onClose} disabled={saving}
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="submit" disabled={saving}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة الفرع" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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 (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px", ...thStyle }}>
<span>الفرع</span>
<span>المدينة</span>
<span>الهاتف</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : branches.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
</p>
{!search && (
<button type="button" onClick={onAddFirst}
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
أضف أول فرع
</button>
)}
</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 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>
<StatusBadge active={b.isActive} />
<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" 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>
);
}

View File

@@ -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 (
<div
role="alertdialog" aria-modal="true" aria-labelledby="branch-del-title"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}
>
<div
onClick={e => e.stopPropagation()}
style={{ width: "100%", maxWidth: 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 */}
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</div>
<div>
<h2 id="branch-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف الفرع</h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف فرع <strong style={{ color: "var(--color-text-primary)" }}>{branch.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={onCancel} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
{deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { BranchTable } from "./BranchTable";
export { BranchFormModal } from "./BranchFormModal";
export { BranchDetailModal } from "./BranchDetailModal";
export { DeleteConfirmModal } from "./DeleteConfirmModal";

View File

@@ -12,6 +12,7 @@ import {
type UpdateClientFormValues, type UpdateClientFormValues,
} from "@/src/validations/client.validator"; } from "@/src/validations/client.validator";
import type { Client } from "@/src/types/client"; import type { Client } from "@/src/types/client";
import { Schema } from "yup";
// ── Styles ───────────────────────────────────────────────────────────────── // ── Styles ─────────────────────────────────────────────────────────────────
const S = { const S = {
@@ -70,7 +71,7 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
setError, setError,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<CreateClientFormValues | UpdateClientFormValues>({ } = useForm<CreateClientFormValues | UpdateClientFormValues>({
resolver: yupResolver(schema) as never, // FIX 1 applied here resolver: yupResolver(Schema) as never, // FIX 1 applied here
defaultValues: { defaultValues: {
name: editClient?.name ?? "", name: editClient?.name ?? "",
email: editClient?.email ?? "", email: editClient?.email ?? "",

View File

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

View File

@@ -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 { ClientFormModal } from "./Clientformmodal";
export { ClientTable } from "./Clienttable"; export { ClientTable } from "./Clienttable";
export { AddressFormModal } from "../Client_Adress/Addressformmodal"; export { AddressFormModal } from "../Client_Adress/Addressformmodal";
export { DeleteConfirmModal } from "./Deleteconfirmmodal"; 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"; export { Toast } from "./Toast";

View File

@@ -0,0 +1,258 @@
"use client";
import type { ClientAddress } from "@/src/types/client_adresses";
// ─── Helpers ───────────────────────────────────────────────────────────────
function labelIcon(label: string): string {
const map: Record<string, string> = {
"فوترة": "💳",
"شحن": "📦",
"المقر الرئيسي": "🏢",
"فرع": "🏬",
"مستودع": "🏭",
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 (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}
>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: "var(--color-text-muted)",
textTransform: "uppercase",
letterSpacing: "0.07em",
}}
>
{label}
</span>
<span
dir={ltr ? "ltr" : "rtl"}
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--color-text-primary)",
fontFamily: ltr ? "var(--font-mono)" : "var(--font-sans)",
}}
>
{value}
</span>
</div>
);
}
function SectionCard({
title,
icon,
children,
}: {
title: string;
icon: string;
children: React.ReactNode;
}) {
return (
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
overflow: "hidden",
}}
>
{/* Card header */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}
>
<span style={{ fontSize: 16 }} aria-hidden="true">{icon}</span>
<p
style={{
margin: 0,
fontSize: 12,
fontWeight: 700,
color: "var(--color-text-secondary)",
letterSpacing: "0.05em",
textTransform: "uppercase",
}}
>
{title}
</p>
</div>
{/* Card body */}
<div
dir="rtl"
style={{ padding: "0 1.5rem" }}
>
{children}
</div>
</div>
);
}
// ─── Main component ────────────────────────────────────────────────────────
interface AddressDetailsProps {
address: ClientAddress;
}
export function AddressDetails({ address }: AddressDetailsProps) {
const { details, contactPerson, location } = address;
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{/* Identity card — label + branch */}
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.25rem 1.5rem",
display: "flex",
alignItems: "center",
gap: "1rem",
}}
dir="rtl"
>
<div
style={{
width: 52,
height: 52,
borderRadius: "var(--radius-lg)",
background: "var(--color-brand-50)",
border: "1px solid var(--color-brand-100)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 24,
flexShrink: 0,
}}
aria-hidden="true"
>
{labelIcon(address.label)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
margin: 0,
fontSize: 18,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
{address.label}
</p>
{address.branchName && (
<p
style={{
margin: "3px 0 0",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
{address.branchName}
</p>
)}
</div>
</div>
{/* Address details */}
<SectionCard title="تفاصيل العنوان" icon="🗺️">
<DetailRow label="الشارع" value={details.street} />
<DetailRow label="المدينة" value={details.city} />
<DetailRow label="المنطقة / الولاية" value={details.state} />
<DetailRow label="الحي" value={details.district} />
<DetailRow label="رقم المبنى" value={details.buildingNo} ltr />
<DetailRow label="رقم الوحدة" value={details.unitNo} ltr />
<DetailRow label="الرقم الإضافي" value={details.additionalNo} ltr />
<DetailRow label="الرمز البريدي" value={details.zipCode} ltr />
<DetailRow label="الشقة / الطابق" value={details.apartment} />
<DetailRow label="الدولة" value={details.country} />
</SectionCard>
{/* Contact person — only when present */}
{(contactPerson?.name || contactPerson?.phone) && (
<SectionCard title="جهة الاتصال" icon="👤">
<DetailRow label="الاسم" value={contactPerson.name} />
<DetailRow label="رقم الهاتف" value={contactPerson.phone} ltr />
</SectionCard>
)}
{/* Coordinates */}
{location?.coordinates && (
<SectionCard title="الإحداثيات الجغرافية" icon="📡">
<DetailRow
label="خط الطول (Longitude)"
value={String(location.coordinates[0])}
ltr
/>
<DetailRow
label="خط العرض (Latitude)"
value={String(location.coordinates[1])}
ltr
/>
</SectionCard>
)}
{/* System metadata */}
<SectionCard title="معلومات النظام" icon="🕐">
<DetailRow
label="تاريخ الإنشاء"
value={new Date(address.createdAt).toLocaleString("ar-SA", {
dateStyle: "long",
timeStyle: "short",
})}
/>
<DetailRow
label="آخر تحديث"
value={new Date(address.updatedAt).toLocaleString("ar-SA", {
dateStyle: "long",
timeStyle: "short",
})}
/>
</SectionCard>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import {
type CreateAddressFormValues, type CreateAddressFormValues,
type UpdateAddressFormValues, type UpdateAddressFormValues,
} from "@/src/validations/client_address.validator"; } from "@/src/validations/client_address.validator";
import type { ClientAddress } from "@/src/types/client"; import type { ClientAddress } from "@/src/types/client_adresses";
// ── Styles ───────────────────────────────────────────────────────────────── // ── Styles ─────────────────────────────────────────────────────────────────
const S = { const S = {
@@ -50,12 +50,9 @@ const S = {
} as React.CSSProperties, } as React.CSSProperties,
}; };
// FIX 2: Use only the `border` shorthand in both branches — no borderColor/border conflict
const withError = (hasError: boolean): React.CSSProperties => ({ const withError = (hasError: boolean): React.CSSProperties => ({
...S.input, ...S.input,
border: hasError border: hasError ? "1px solid var(--color-danger)" : "1px solid var(--color-border)",
? "1px solid var(--color-danger)"
: "1px solid var(--color-border)",
background: hasError ? "#FEF2F2" : S.input.background, background: hasError ? "#FEF2F2" : S.input.background,
}); });
@@ -63,39 +60,27 @@ const withError = (hasError: boolean): React.CSSProperties => ({
interface AddressFormModalProps { interface AddressFormModalProps {
editAddress: ClientAddress | null; editAddress: ClientAddress | null;
onClose: () => void; onClose: () => void;
// FIX: removed the "isNew" second argument. The parent (page.tsx) now onSubmit: (data: CreateAddressFormValues | UpdateAddressFormValues) => Promise<boolean>;
// 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<boolean>;
} }
// ── Component ────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────
export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressFormModalProps) { export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressFormModalProps) {
const isNew = editAddress === null; const isNew = editAddress === null;
// NOTE: "schema" variable removed — resolver now picks createAddressSchema
// or updateAddressSchema directly inline (see useForm below).
const { const {
register, register,
handleSubmit, handleSubmit,
setError, setError,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<CreateAddressFormValues | UpdateAddressFormValues>({ } = useForm<CreateAddressFormValues | UpdateAddressFormValues>({
// FIX: "as never" was hiding all type errors on resolver/errors. resolver: (isNew
// yupResolver only works with ONE concrete schema type, not a union, ? yupResolver(createAddressSchema)
// so we still need a cast — but cast to the real Resolver type instead : yupResolver(updateAddressSchema)
// of "never", so formState.errors keeps proper typing. ) as Resolver<CreateAddressFormValues | UpdateAddressFormValues>,
resolver: (isNew ? yupResolver(createAddressSchema) : yupResolver(updateAddressSchema)) as Resolver<
CreateAddressFormValues | UpdateAddressFormValues
>,
defaultValues: { defaultValues: {
label: editAddress?.label ?? "", label: editAddress?.label ?? "",
branchName: editAddress?.branchName ?? "", branchName: editAddress?.branchName ?? "",
isPrimary: editAddress?.isPrimary ?? false,
contactPerson: { contactPerson: {
name: editAddress?.contactPerson?.name ?? "", name: editAddress?.contactPerson?.name ?? "",
@@ -121,9 +106,9 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
}, },
}); });
const detailsErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.details ?? {}; const detailsErr = (errors as any)?.details ?? {};
const contactErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.contactPerson ?? {}; const contactErr = (errors as any)?.contactPerson ?? {};
const locationErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.location ?? {}; const locationErr = (errors as any)?.location ?? {};
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
@@ -132,7 +117,7 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
}, [onClose]); }, [onClose]);
const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => { 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) { if (ok) {
onClose(); onClose();
} else { } else {
@@ -173,7 +158,7 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
flexDirection: "column", flexDirection: "column",
}} }}
> >
{/* Header — pinned */} {/* Header */}
<div <div
style={{ style={{
flexShrink: 0, flexShrink: 0,
@@ -186,26 +171,12 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
}} }}
> >
<div> <div>
<p <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
{isNew ? "إضافة عنوان" : "تعديل عنوان"} {isNew ? "إضافة عنوان" : "تعديل عنوان"}
</p> </p>
<h2 <h2
id="addr-modal-title" id="addr-modal-title"
style={{ style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}
fontSize: 17,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: "4px 0 0",
}}
> >
{isNew ? "عنوان جديد" : editAddress?.label} {isNew ? "عنوان جديد" : editAddress?.label}
</h2> </h2>
@@ -237,12 +208,7 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
<form <form
onSubmit={handleSubmit(submitHandler)} onSubmit={handleSubmit(submitHandler)}
noValidate noValidate
style={{ style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}
padding: "1.5rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
> >
{errors.label?.type === "manual" && ( {errors.label?.type === "manual" && (
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} /> <Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
@@ -251,58 +217,22 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
{/* Address Meta */} {/* Address Meta */}
<p style={S.sectionTitle}>بيانات العنوان</p> <p style={S.sectionTitle}>بيانات العنوان</p>
<div <label style={S.label}>
style={{ نوع العنوان
display: "grid", <input
gridTemplateColumns: "1fr auto", {...register("label")}
gap: "0.75rem", style={withError(!!errors.label && errors.label.type !== "manual")}
alignItems: "end", placeholder="مثال: منزل / مكتب / شحن"
}} dir="rtl"
> />
{/* FIX 3: Replaced <select> with a free-text <input> */} {errors.label && errors.label.type !== "manual" && (
<label style={S.label}> <span style={S.errorText}>{errors.label.message}</span>
نوع العنوان )}
<input </label>
{...register("label")}
style={withError(!!errors.label && errors.label.type !== "manual")}
placeholder="مقترح: منزل / مكتب"
dir="rtl"
/>
{errors.label && errors.label.type !== "manual" && (
<span style={S.errorText}>{errors.label.message}</span>
)}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-secondary)",
paddingBottom: 2,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
<input
type="checkbox"
{...register("isPrimary")}
style={{ width: 14, height: 14, cursor: "pointer" }}
/>
عنوان رئيسي
</label>
</div>
<label style={S.label}> <label style={S.label}>
اسم الفرع (اختياري) اسم الفرع (اختياري)
<input <input {...register("branchName")} style={S.input} placeholder="فرع الرياض" dir="rtl" />
{...register("branchName")}
style={S.input}
placeholder="فرع الرياض"
dir="rtl"
/>
</label> </label>
{/* Address Details */} {/* Address Details */}
@@ -316,119 +246,60 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
placeholder="شارع الملك فهد، مبنى 12" placeholder="شارع الملك فهد، مبنى 12"
dir="rtl" dir="rtl"
/> />
{detailsErr.street && ( {detailsErr.street && <span style={S.errorText}>{detailsErr.street.message}</span>}
<span style={S.errorText}>{detailsErr.street.message}</span>
)}
</label> </label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <label style={S.label}>
المدينة {isNew && "*"} المدينة {isNew && "*"}
<input <input {...register("details.city")} style={withError(!!detailsErr.city)} placeholder="الرياض" dir="rtl" />
{...register("details.city")} {detailsErr.city && <span style={S.errorText}>{detailsErr.city.message}</span>}
style={withError(!!detailsErr.city)}
placeholder="الرياض"
dir="rtl"
/>
{detailsErr.city && (
<span style={S.errorText}>{detailsErr.city.message}</span>
)}
</label> </label>
<label style={S.label}> <label style={S.label}>
المنطقة المنطقة
<input <input {...register("details.state")} style={withError(!!detailsErr.state)} placeholder="منطقة الرياض" dir="rtl" />
{...register("details.state")} {detailsErr.state && <span style={S.errorText}>{detailsErr.state.message}</span>}
style={withError(!!detailsErr.state)}
placeholder="منطقة الرياض"
dir="rtl"
/>
{detailsErr.state && (
<span style={S.errorText}>{detailsErr.state.message}</span>
)}
</label> </label>
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <label style={S.label}>
الحي (اختياري) الحي (اختياري)
<input <input {...register("details.district")} style={S.input} placeholder="حي العليا" dir="rtl" />
{...register("details.district")}
style={S.input}
placeholder="حي العليا"
dir="rtl"
/>
</label> </label>
<label style={S.label}> <label style={S.label}>
رقم المبنى (اختياري) رقم المبنى (اختياري)
<input <input {...register("details.buildingNo")} style={S.input} placeholder="1234" dir="ltr" />
{...register("details.buildingNo")}
style={S.input}
placeholder="1234"
dir="ltr"
/>
</label> </label>
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <label style={S.label}>
رقم الوحدة (اختياري) رقم الوحدة (اختياري)
<input <input {...register("details.unitNo")} style={S.input} placeholder="5678" dir="ltr" />
{...register("details.unitNo")}
style={S.input}
placeholder="5678"
dir="ltr"
/>
</label> </label>
<label style={S.label}> <label style={S.label}>
الرقم الإضافي (اختياري) الرقم الإضافي (اختياري)
<input <input {...register("details.additionalNo")} style={S.input} placeholder="0000" dir="ltr" />
{...register("details.additionalNo")}
style={S.input}
placeholder="0000"
dir="ltr"
/>
</label> </label>
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <label style={S.label}>
الرمز البريدي (اختياري) الرمز البريدي (اختياري)
<input <input {...register("details.zipCode")} style={withError(!!detailsErr.zipCode)} placeholder="11564" dir="ltr" />
{...register("details.zipCode")} {detailsErr.zipCode && <span style={S.errorText}>{detailsErr.zipCode.message}</span>}
style={withError(!!detailsErr.zipCode)}
placeholder="11564"
dir="ltr"
/>
{detailsErr.zipCode && (
<span style={S.errorText}>{detailsErr.zipCode.message}</span>
)}
</label> </label>
<label style={S.label}> <label style={S.label}>
الدولة الدولة
<input <input {...register("details.country")} style={withError(!!detailsErr.country)} placeholder="SA" dir="ltr" />
{...register("details.country")} {detailsErr.country && <span style={S.errorText}>{detailsErr.country.message}</span>}
style={withError(!!detailsErr.country)}
placeholder="SA"
dir="ltr"
/>
{detailsErr.country && (
<span style={S.errorText}>{detailsErr.country.message}</span>
)}
</label> </label>
</div> </div>
<label style={S.label}> <label style={S.label}>
الشقة / الطابق (اختياري) الشقة / الطابق (اختياري)
<input <input {...register("details.apartment")} style={S.input} placeholder="الطابق الثالث" dir="rtl" />
{...register("details.apartment")}
style={S.input}
placeholder="الطابق الثالث"
dir="rtl"
/>
</label> </label>
{/* Contact Person */} {/* Contact Person */}
@@ -437,29 +308,13 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <label style={S.label}>
الاسم الاسم
<input <input {...register("contactPerson.name")} style={withError(!!contactErr.name)} placeholder="أحمد محمد" dir="rtl" />
{...register("contactPerson.name")} {contactErr.name && <span style={S.errorText}>{contactErr.name.message}</span>}
style={withError(!!contactErr.name)}
placeholder="أحمد محمد"
dir="rtl"
/>
{contactErr.name && (
<span style={S.errorText}>{contactErr.name.message}</span>
)}
</label> </label>
<label style={S.label}> <label style={S.label}>
رقم الهاتف رقم الهاتف
<input <input {...register("contactPerson.phone")} style={withError(!!contactErr.phone)} type="tel" placeholder="05xxxxxxxx" dir="ltr" />
{...register("contactPerson.phone")} {contactErr.phone && <span style={S.errorText}>{contactErr.phone.message}</span>}
style={withError(!!contactErr.phone)}
type="tel"
placeholder="05xxxxxxxx"
dir="ltr"
/>
{contactErr.phone && (
<span style={S.errorText}>{contactErr.phone.message}</span>
)}
</label> </label>
</div> </div>
@@ -469,41 +324,17 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}> <label style={S.label}>
خط الطول (Longitude) {isNew && "*"} خط الطول (Longitude) {isNew && "*"}
<input <input {...register("location.coordinates.0" as never)} style={withError(!!locationErr.coordinates)} type="number" step="any" placeholder="46.6753" dir="ltr" />
{...register("location.coordinates.0" as never)}
style={withError(!!locationErr.coordinates)}
type="number"
step="any"
placeholder="46.6753"
dir="ltr"
/>
</label> </label>
<label style={S.label}> <label style={S.label}>
خط العرض (Latitude) {isNew && "*"} خط العرض (Latitude) {isNew && "*"}
<input <input {...register("location.coordinates.1" as never)} style={withError(!!locationErr.coordinates)} type="number" step="any" placeholder="24.7136" dir="ltr" />
{...register("location.coordinates.1" as never)} {locationErr.coordinates && <span style={S.errorText}>{locationErr.coordinates.message}</span>}
style={withError(!!locationErr.coordinates)}
type="number"
step="any"
placeholder="24.7136"
dir="ltr"
/>
{locationErr.coordinates && (
<span style={S.errorText}>{locationErr.coordinates.message}</span>
)}
</label> </label>
</div> </div>
{/* Actions */} {/* Actions */}
<div <div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
paddingTop: "0.5rem",
}}
>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -531,9 +362,7 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
padding: "0 1.5rem", padding: "0 1.5rem",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: "none", border: "none",
background: isSubmitting background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
? "var(--color-brand-400)"
: "var(--color-brand-600)",
fontSize: 13, fontSize: 13,
fontWeight: 700, fontWeight: 700,
color: "#FFF", color: "#FFF",

View File

@@ -28,7 +28,7 @@ export function DeleteConfirmModal({ user, deleting, onCancel, onConfirm }: Dele
onClick={e => e.stopPropagation()} onClick={e => 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" }} 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 */}
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}> <div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /> <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" />

View File

@@ -1,56 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import type { Notification } from "@/src/hooks/useUser";
interface ToastProps {
notification: Notification | null;
}
export function Toast({ notification }: ToastProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (notification) {
setVisible(true);
} else {
// dismiss after 250ms to allow exit animation
const t = setTimeout(() => setVisible(false), 300);
return () => clearTimeout(t);
}
}, [notification]);
if (!visible && !notification) return null;
const isSuccess = notification?.type === "success";
return (
<div
role="status" aria-live="polite" aria-atomic="true"
style={{
position: "fixed", bottom: 24, left: "50%",
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
zIndex: 9999,
transition: "transform 250ms ease, opacity 250ms ease",
opacity: notification ? 1 : 0,
pointerEvents: "none",
}}
>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: isSuccess ? "#065F46" : "#7F1D1D",
color: "#FFFFFF",
fontSize: 13, fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
maxWidth: "90vw", whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}>
{/* icon */}
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
<span>{notification?.message}</span>
</div>
</div>
);
}

View File

@@ -2,4 +2,3 @@ export { UserTable } from "./UserTable";
export { UserFormModal } from "./UserFormModal"; export { UserFormModal } from "./UserFormModal";
export { UserDetailModal } from "./UserDetailModal"; export { UserDetailModal } from "./UserDetailModal";
export { DeleteConfirmModal } from "./DeleteConfirmModal"; export { DeleteConfirmModal } from "./DeleteConfirmModal";
export { Toast } from "./Toast";

View File

@@ -1,6 +1,4 @@
"use client"; "use client";
// Components/Car/CarImageGallery.tsx
// Full-screen gallery modal for viewing, uploading and deleting car images.
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";

View File

@@ -1,10 +1,8 @@
"use client"; "use client";
// Components/Role/DeleteRoleModal.tsx
// Confirm deletion of a role — mirrors DeleteConfirmModal from Users.
import { useEffect } from "react"; import { useEffect } from "react";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { Role } from "../../../services/role.service"; import { Role } from "@/src/types/role";
interface DeleteRoleModalProps { interface DeleteRoleModalProps {
role: Role; role: Role;
@@ -15,18 +13,14 @@ interface DeleteRoleModalProps {
export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) { export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) {
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", h); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", h); return () => window.removeEventListener("keydown", handler);
}, [onCancel]); }, [onCancel]);
const permCount = role.permissions?.length ?? 0;
return ( return (
<div <div
role="alertdialog" role="alertdialog" aria-modal="true" aria-labelledby="del-role-title"
aria-modal="true"
aria-labelledby="del-role-title"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{ style={{
position: "fixed", inset: 0, zIndex: 60, position: "fixed", inset: 0, zIndex: 60,
@@ -34,30 +28,22 @@ export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteR
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}} }}
> >
<div <div onClick={e => e.stopPropagation()} style={{
onClick={e => e.stopPropagation()} width: "100%", maxWidth: 400,
style={{ background: "var(--color-surface)",
width: "100%", maxWidth: 420, borderRadius: "var(--radius-xl)",
background: "var(--color-surface)", border: "1px solid #FECACA",
borderRadius: "var(--radius-xl)", boxShadow: "0 20px 48px rgba(0,0,0,.18)",
border: "1px solid #FECACA", padding: "2rem",
boxShadow: "0 20px 48px rgba(0,0,0,.18)", display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center",
padding: "2rem", }}>
display: "flex", flexDirection: "column", gap: "1rem",
textAlign: "center",
}}
>
{/* Icon */} {/* Icon */}
<div style={{ <div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
width: 52, height: 52, margin: "0 auto",
borderRadius: "50%",
background: "#FEF2F2", border: "1px solid #FECACA",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/> <polyline points="3 6 5 6 21 6" />
<line x1="12" y1="8" x2="12" y2="12"/> <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<line x1="12" y1="16" x2="12.01" y2="16"/> <path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg> </svg>
</div> </div>
@@ -66,60 +52,18 @@ export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteR
حذف الدور حذف الدور
</h2> </h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}> <p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف دور{" "} هل أنت متأكد من حذف دور <strong style={{ color: "var(--color-text-primary)" }}>{role.name}</strong>؟
<strong style={{ color: "var(--color-text-primary)" }}>{role.name}</strong>؟
</p>
{permCount > 0 && (
<p style={{
marginTop: 8, fontSize: 12,
background: "#FEF3C7", color: "#92400E",
border: "1px solid #FDE68A",
borderRadius: "var(--radius-md)",
padding: "0.5rem 0.75rem",
}}>
يحتوي هذا الدور على {permCount} صلاحية مرتبطة، سيتم إلغاء ارتباطها تلقائياً.
</p>
)}
<p style={{ marginTop: 6, fontSize: 12, color: "#DC2626" }}>
لا يمكن التراجع عن هذا الإجراء. لا يمكن التراجع عن هذا الإجراء.
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
<button <button type="button" onClick={onCancel} disabled={deleting}
type="button" style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
onClick={onCancel}
disabled={deleting}
style={{
flex: 1, height: 40,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: deleting ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء إلغاء
</button> </button>
<button <button type="button" onClick={onConfirm} disabled={deleting}
type="button" style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
onClick={onConfirm}
disabled={deleting}
style={{
flex: 1, height: 40,
borderRadius: "var(--radius-md)",
border: "none",
background: "#DC2626",
fontSize: 13, fontWeight: 700,
color: "#FFF",
cursor: deleting ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
fontFamily: "var(--font-sans)",
opacity: deleting ? 0.7 : 1,
}}
>
{deleting && <Spinner size="sm" className="text-white" />} {deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"} {deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button> </button>

View File

@@ -1,375 +1,200 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Alert, Spinner } from "../UI"; import { Spinner } from "../UI";
import type { Role, Permission } from "../../../services/role.service"; import { getStoredToken } from "@/src/lib/auth";
import { roleService } from "@/src/services/role.service";
import { Role } from "@/src/types/role";
const MODULE_LABELS: Record<string, string> = {
Role: "الأدوار",
User: "المستخدمون",
Branch: "الفروع",
Car: "المركبات",
CarImage: "صور المركبات",
Driver: "السائقون",
Order: "الطلبات",
Trip: "الرحلات",
Client: "العملاء",
Permission: "الصلاحيات",
Audit: "سجل التدقيق",
Dashboard: "لوحة التحكم",
Maintenance: "الصيانة",
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
function groupByModule(
permissions: Permission[],
): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
(acc[p.module || "أخرى"] ??= []).push(p);
return acc;
}, {});
}
interface RoleDetailModalProps { interface RoleDetailModalProps {
role: Role; roleId: string;
allPermissions: Permission[];
onClose: () => void; onClose: () => void;
onSave: (roleId: string, permissionIds: string[]) => Promise<boolean>;
} }
export function RoleDetailModal({ function DetailRow({ label, value }: { label: string; value?: string | null }) {
role, return (
allPermissions, <div style={{
onClose, display: "flex", flexDirection: "column", gap: 4,
onSave, padding: "0.75rem 0", borderBottom: "1px solid var(--color-border)",
}: RoleDetailModalProps) { }}>
const currentIds = role.permissions?.map((rp) => rp.permission.id) ?? []; <span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds)); {label}
const [saving, setSaving] = useState(false); </span>
const [apiError, setApiError] = useState(""); <span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
const [saved, setSaved] = useState(false); {value || "—"}
</span>
</div>
);
}
const grouped = groupByModule(allPermissions); function StatusBadge({ active }: { active: boolean }) {
const activeSet = new Set(currentIds); return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem", fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
const toggle = (id: string) => { export function RoleDetailModal({ roleId, onClose }: RoleDetailModalProps) {
setSelected((prev) => { const [role, setRole] = useState<Role | null>(null);
const next = new Set(prev); const [loading, setLoading] = useState(true);
next.has(id) ? next.delete(id) : next.add(id); const [error, setError] = useState<string | null>(null);
return next;
});
setSaved(false);
};
const handleSave = async () => { useEffect(() => {
setSaving(true); const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
setApiError(""); window.addEventListener("keydown", handler);
const ok = await onSave(role.id, [...selected]); return () => window.removeEventListener("keydown", handler);
setSaving(false); }, [onClose]);
if (ok) {
setSaved(true); useEffect(() => {
setTimeout(() => setSaved(false), 2000); let cancelled = false;
} else { (async () => {
setApiError("حدث خطأ أثناء حفظ الصلاحيات. حاول مرة أخرى."); 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 ( return (
<div <div
role="dialog" role="dialog" aria-modal="true" aria-labelledby="role-detail-title"
aria-modal="true" onClick={e => { if (e.target === e.currentTarget) onClose(); }}
aria-labelledby="role-detail-title"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{ style={{
position: "fixed", position: "fixed", inset: 0, zIndex: 55,
inset: 0, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
background: "rgba(15,23,42,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
}} }}
> >
<div <div onClick={e => e.stopPropagation()} style={{
onClick={(e) => e.stopPropagation()} width: "100%", maxWidth: 520,
dir="rtl" background: "var(--color-surface)",
style={{ borderRadius: "var(--radius-2xl)",
width: "100%", border: "1px solid var(--color-border)",
maxWidth: 640, boxShadow: "0 24px 64px rgba(0,0,0,.18)",
background: "var(--color-surface)", overflow: "hidden", display: "flex", flexDirection: "column",
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 */} {/* Header */}
<div <div style={{
style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
display: "flex", padding: "1.25rem 1.5rem",
alignItems: "center", borderBottom: "1px solid var(--color-border)",
justifyContent: "space-between", background: "var(--color-surface-muted)",
padding: "1.25rem 1.5rem", }}>
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
flexShrink: 0,
}}
>
<div> <div>
<p <p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
تفاصيل الدور تفاصيل الدور
</p> </p>
<h2 <h2 id="role-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
id="role-detail-title" {role?.name ?? "عرض الدور"}
style={{
fontSize: 17,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: "4px 0 0",
}}
>
{role.name}
</h2> </h2>
{role.description && (
<p
style={{
fontSize: 12,
color: "var(--color-text-muted)",
marginTop: 4,
}}
>
{role.description}
</p>
)}
</div> </div>
<button <button type="button" onClick={onClose} aria-label="إغلاق"
type="button" style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
onClick={onClose}
aria-label="إغلاق"
style={{
width: 34,
height: 34,
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer",
fontSize: 18,
color: "var(--color-text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
× ×
</button> </button>
</div> </div>
{/* Body */} {/* Body */}
<div <div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
style={{ {loading && (
padding: "1.5rem", <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
overflowY: "auto", <Spinner size="sm" className="text-blue-600" />
flex: 1, <span style={{ fontSize: 13 }}>جارٍ التحميل</span>
display: "flex", </div>
flexDirection: "column",
gap: "1rem",
}}
>
{apiError && (
<Alert
type="error"
message={apiError}
onClose={() => setApiError("")}
/>
)}
{saved && (
<Alert
type="success"
message="تم حفظ التغييرات بنجاح."
onClose={() => setSaved(false)}
/>
)} )}
<p style={{ fontSize: 12, color: "var(--color-text-muted)" }}> {!loading && error && (
{selected.size} من {allPermissions.length} صلاحية مفعّلة لهذا الدور <div style={{ padding: "1rem 1.25rem", borderRadius: "var(--radius-lg)", background: "#FEF2F2", border: "1px solid #FECACA", fontSize: 13, color: "#991B1B", fontWeight: 500, textAlign: "center" }}>
</p> {error}
</div>
)}
<div {!loading && role && (
style={{ <div dir="rtl">
border: "1px solid var(--color-border)", {/* Role icon + name + status */}
borderRadius: "var(--radius-lg)", <div style={{ display: "flex", alignItems: "center", gap: "1rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--color-border)", marginBottom: "0.25rem" }}>
overflow: "hidden", <div style={{
}} width: 56, height: 56, borderRadius: "var(--radius-xl)",
> background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
{Object.entries(grouped).map(([mod, perms], idx) => ( display: "flex", alignItems: "center", justifyContent: "center",
<div fontSize: 22, flexShrink: 0,
key={mod} }}>
style={{ 🛡
borderBottom:
idx < Object.keys(grouped).length - 1
? "1px solid var(--color-border)"
: "none",
}}
>
<div
style={{
padding: "0.625rem 1rem",
background: "var(--color-surface-muted)",
fontSize: 12,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
{moduleLabel(mod)}
</div> </div>
<div <div style={{ flex: 1, minWidth: 0 }}>
style={{ <p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
display: "grid", {role.description && (
gridTemplateColumns: <p style={{ marginTop: 3, fontSize: 12, color: "var(--color-text-muted)" }}>{role.description}</p>
"repeat(auto-fill, minmax(180px, 1fr))", )}
gap: "0.5rem", <div style={{ marginTop: 8 }}>
padding: "0.75rem 1rem", <StatusBadge active={role.isActive} />
}} </div>
>
{perms.map((perm) => {
const checked = selected.has(perm.id);
const wasActive = activeSet.has(perm.id);
return (
<label
key={perm.id}
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
padding: "0.5rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked
? "#EFF6FF"
: "var(--color-surface-muted)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(perm.id)}
style={{
width: 14,
height: 14,
marginTop: 1,
cursor: "pointer",
accentColor: "#2563EB",
}}
/>
<div>
<p
style={{
fontSize: 11,
fontWeight: 600,
color: checked
? "#1D4ED8"
: "var(--color-text-primary)",
margin: 0,
}}
>
{perm.name}
</p>
<span
style={{
fontSize: 9,
fontWeight: 700,
padding: "1px 6px",
borderRadius: "var(--radius-full)",
color: wasActive ? "#166534" : "#991B1B",
background: wasActive ? "#DCFCE7" : "#FEF2F2",
}}
>
{wasActive ? "نشطة حالياً" : "غير نشطة"}
</span>
</div>
</label>
);
})}
</div> </div>
</div> </div>
))}
</div> <DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
{role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
{/* Permissions list */}
{role.permissions && role.permissions.length > 0 && (
<div style={{ paddingTop: "0.75rem" }}>
<p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 10px" }}>
الصلاحيات ({role.permissions.length})
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
{role.permissions.map(({ permission }) => (
<span key={permission.id} style={{
display: "inline-flex", alignItems: "center",
padding: "0.25rem 0.6rem", borderRadius: "var(--radius-md)",
background: "#EFF6FF", border: "1px solid #BFDBFE",
fontSize: 11, fontWeight: 600, color: "#1D4ED8",
}}>
{permission.name}
</span>
))}
</div>
</div>
)}
{(!role.permissions || role.permissions.length === 0) && (
<div style={{ paddingTop: "0.75rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-hint)", fontStyle: "italic" }}>لا توجد صلاحيات مسندة لهذا الدور</p>
</div>
)}
</div>
)}
</div> </div>
{/* Footer */} {/* Footer */}
<div <div style={{
style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)",
display: "flex", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end",
gap: "0.5rem", }}>
justifyContent: "flex-end", <button type="button" onClick={onClose}
padding: "1rem 1.5rem", style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
borderTop: "1px solid var(--color-border)",
flexShrink: 0,
}}
>
<button
type="button"
onClick={onClose}
disabled={saving}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: saving ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إغلاق إغلاق
</button> </button>
<button
type="button"
onClick={handleSave}
disabled={saving}
style={{
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: saving
? "var(--color-brand-400)"
: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: saving ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : "حفظ التغييرات"}
</button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import * as yup from "yup";
import { Alert, Spinner } from "../UI"; 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 ──────────────────────────────────────────────────────────────────── // ── Styles ────────────────────────────────────────────────────────────────────
const S = { const S = {
input: { input: {
width: "100%", height: 40, padding: "0 0.75rem", width: "100%", height: 40, padding: "0 0.75rem",
@@ -21,179 +21,137 @@ const S = {
gap: 6, fontSize: 12, fontWeight: 600, gap: 6, fontSize: 12, fontWeight: 600,
color: "var(--color-text-secondary)", color: "var(--color-text-secondary)",
} as React.CSSProperties, } as React.CSSProperties,
errorText: { errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties,
fontSize: 11, color: "var(--color-danger)", fontWeight: 500,
} as React.CSSProperties,
}; };
// ── Module label map (Arabic) ───────────────────────────────────────────────── // ── Validation ────────────────────────────────────────────────────────────────
async function validate(data: RoleFormData, isNew: boolean): Promise<RoleFormErrors> {
const MODULE_LABELS: Record<string, string> = { const schema = isNew ? createRoleSchema : updateRoleSchema;
Role: "الأدوار", try {
User: "المستخدمون", await schema.validate(data, { abortEarly: false });
Branch: "الفروع", return {};
Car: "المركبات", } catch (err) {
CarImage: "صور المركبات", if (err instanceof yup.ValidationError) {
Driver: "السائقون", return err.inner.reduce<RoleFormErrors>((acc, e) => {
Order: "الطلبات", const field = e.path as keyof RoleFormErrors;
Trip: "الرحلات", if (field && !acc[field]) acc[field] = e.message;
Client: "العملاء", return acc;
Permission: "الصلاحيات", }, {});
Audit: "سجل التدقيق", }
Dashboard: "لوحة التحكم", return {};
Maintenance: "الصيانة", }
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Group permissions by their module field */
function groupByModule(permissions: Permission[]): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
const key = p.module || "أخرى";
if (!acc[key]) acc[key] = [];
acc[key].push(p);
return acc;
}, {});
} }
interface FormErrors { // ── Permission checkbox group ─────────────────────────────────────────────────
name?: string; /*function PermissionGroup({
} module, perms, selected, onToggle,
}: {
function validate(name: string): FormErrors { module: string; perms: Permission[]; selected: string[]; onToggle: (id: string) => void;
const e: FormErrors = {}; }) {
if (!name.trim()) e.name = "اسم الدور مطلوب"; return (
else if (name.trim().length < 2) e.name = "الاسم يجب أن يكون حرفين على الأقل"; <div style={{ marginBottom: "0.75rem" }}>
return e; <p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 6px" }}>
} {module}
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
{perms.map(p => {
const checked = selected.includes(p.id);
return (
<label key={p.id} style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "0.3rem 0.6rem", borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked ? "#EFF6FF" : "var(--color-surface-muted)",
cursor: "pointer", fontSize: 12, fontWeight: 500,
color: checked ? "#1D4ED8" : "var(--color-text-secondary)",
transition: "all 150ms",
}}>
<input
type="checkbox" checked={checked} onChange={() => onToggle(p.id)}
style={{ accentColor: "#2563EB", cursor: "pointer" }}
/>
{p.name}
</label>
);
})}
</div>
</div>
);
}*/
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
interface RoleFormModalProps { interface RoleFormModalProps {
editRole: Role | null; // null = create mode editRole: Role | null;
permissions: Permission[]; // all available permissions permissions: Permission[];
onClose: () => void; onClose: () => void;
onSubmit: (data: RoleFormData, currentPermIds: string[]) => Promise<boolean>; onSubmit: (data: RoleFormData, isNew: boolean) => Promise<boolean>;
} }
// ── Component ───────────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: RoleFormModalProps) { export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: RoleFormModalProps) {
const isNew = editRole === null; const isNew = editRole === null;
// Current permission IDs attached to the role being edited // Group permissions by module
const currentPermIds = editRole?.permissions?.map(rp => rp.permission.id) ?? []; const grouped = permissions.reduce<Record<string, Permission[]>>((acc, p) => {
if (!acc[p.module]) acc[p.module] = [];
acc[p.module].push(p);
return acc;
}, {});
const [name, setName] = useState(editRole?.name ?? ""); const currentPermIds = editRole?.permissions?.map(p => p.permission.id) ?? [];
const [description, setDescription] = useState(editRole?.description ?? "");
const [selectedPerms, setSelectedPerms] = useState<Set<string>>(new Set(currentPermIds));
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const [searchPerm, setSearchPerm] = useState("");
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
const firstInputRef = useRef<HTMLInputElement>(null);
const grouped = groupByModule(permissions); const [form, setForm] = useState<RoleFormData>({
name: editRole?.name ?? "",
description: editRole?.description ?? "",
permissionIds: currentPermIds,
});
const [errors, setErrors] = useState<RoleFormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(null);
// Auto-expand modules that have selected permissions or match search useEffect(() => { firstInputRef.current?.focus(); }, []);
useEffect(() => { useEffect(() => {
if (editRole) { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
const modulesWithSelected = new Set<string>(); window.addEventListener("keydown", handler);
permissions.forEach(p => { return () => window.removeEventListener("keydown", handler);
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);
}, [onClose]); }, [onClose]);
// ── Checkbox helpers ─────────────────────────────────────────────────────── const set = (field: keyof Pick<RoleFormData, "name" | "description">) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm(p => ({ ...p, [field]: e.target.value }));
if (errors[field]) setErrors(p => ({ ...p, [field]: undefined }));
};
const togglePerm = (id: string) => { const togglePerm = (id: string) => {
setSelectedPerms(prev => { setForm(p => ({
const next = new Set(prev); ...p,
if (next.has(id)) next.delete(id); permissionIds: p.permissionIds.includes(id)
else next.add(id); ? p.permissionIds.filter(x => x !== id)
return next; : [...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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const errs = validate(name); const errs = await validate(form, isNew);
if (Object.keys(errs).length) { setErrors(errs); return; } if (Object.keys(errs).length) { setErrors(errs); return; }
setSaving(true); setSaving(true);
setApiError(""); setApiError("");
const ok = await onSubmit( const ok = await onSubmit(form, isNew);
{ name: name.trim(), description: description.trim(), permissionIds: [...selectedPerms] },
currentPermIds,
);
setSaving(false); setSaving(false);
if (ok) onClose(); if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}; };
// ── Filter permissions by search ─────────────────────────────────────────── const inputStyle = (field: keyof RoleFormErrors): React.CSSProperties => ({
...S.input,
const filteredGrouped = Object.entries(grouped).reduce<Record<string, Permission[]>>((acc, [mod, perms]) => { ...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
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;
return ( return (
<div <div
role="dialog" role="dialog" aria-modal="true" aria-labelledby="role-modal-title"
aria-modal="true"
aria-labelledby="role-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }} onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{ style={{
position: "fixed", inset: 0, zIndex: 50, position: "fixed", inset: 0, zIndex: 50,
@@ -204,17 +162,16 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
<div <div
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
style={{ style={{
width: "100%", maxWidth: 640, width: "100%", maxWidth: 600,
background: "var(--color-surface)", background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)", borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)", boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden", overflow: "hidden", display: "flex", flexDirection: "column",
display: "flex", flexDirection: "column",
maxHeight: "90vh", maxHeight: "90vh",
}} }}
> >
{/* ── Modal header ── */} {/* Header */}
<div style={{ <div style={{
display: "flex", alignItems: "center", justifyContent: "space-between", display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem", padding: "1.25rem 1.5rem",
@@ -227,278 +184,90 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
{isNew ? "إضافة دور" : "تعديل دور"} {isNew ? "إضافة دور" : "تعديل دور"}
</p> </p>
<h2 id="role-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> <h2 id="role-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "دور جديد" : editRole.name} {isNew ? "دور جديد" : editRole?.name}
</h2> </h2>
</div> </div>
<button <button type="button" onClick={onClose} aria-label="إغلاق"
type="button" style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
onClick={onClose}
aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)", cursor: "pointer",
fontSize: 18, color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
× ×
</button> </button>
</div> </div>
{/* ── Scrollable body ── */} {/* Body */}
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit} noValidate
noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto", flex: 1 }}
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.25rem", overflowY: "auto", flex: 1 }}
> >
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />} {apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
{/* Name field */} {/* Name */}
<label style={S.label}> <label style={S.label} dir="rtl">
اسم الدور * اسم الدور *
<input <input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="مثال: مدير الفروع" dir="rtl" />
ref={firstInputRef}
value={name}
onChange={e => { setName(e.target.value); if (errors.name) setErrors({}); }}
placeholder="مدير النظام"
autoComplete="off"
dir="rtl"
style={{
...S.input,
...(errors.name ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
}}
/>
{errors.name && <span style={S.errorText}>{errors.name}</span>} {errors.name && <span style={S.errorText}>{errors.name}</span>}
</label> </label>
{/* Description field */} {/* Description */}
<label style={S.label}> <label style={S.label} dir="rtl">
الوصف (اختياري) الوصف
<textarea <textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="وصف مختصر لمهام هذا الدور…"
rows={2}
dir="rtl"
style={{ style={{
...S.input, ...S.input, height: "auto", padding: "0.5rem 0.75rem", resize: "none",
height: "auto", ...(errors.description ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
padding: "0.5rem 0.75rem", } as React.CSSProperties}
resize: "vertical", rows={3}
minHeight: 60, value={form.description}
}} onChange={set("description")}
placeholder="وصف مختصر لمهام هذا الدور…"
dir="rtl"
/> />
{errors.description && <span style={S.errorText}>{errors.description}</span>}
</label> </label>
{/* ── Permissions section ── */} {/* Permissions */}
<div> {/*{permissions.length > 0 && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}> <div dir="rtl">
<div> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<p style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-secondary)", margin: 0 }}> <span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
الصلاحيات الصلاحيات
</p> </span>
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 2 }}> <div style={{ display: "flex", gap: 8 }}>
{totalSelected} من {permissions.length} صلاحية محددة <button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: permissions.map(x => x.id) }))}
</p> style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
تحديد الكل
</button>
<button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: [] }))}
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
إلغاء الكل
</button>
</div>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={selectAll}
style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "var(--color-brand-50)", border: "1px solid var(--color-brand-200)", borderRadius: "var(--radius-md)", padding: "4px 10px", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
تحديد الكل
</button>
<button type="button" onClick={clearAll}
style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", background: "var(--color-surface-muted)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)", padding: "4px 10px", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء الكل
</button>
</div>
</div>
{/* Permission search */}
<div style={{ position: "relative", marginBottom: "0.75rem" }}>
<svg style={{ position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)", width: 14, height: 14, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input
type="text"
placeholder="بحث في الصلاحيات…"
value={searchPerm}
onChange={e => setSearchPerm(e.target.value)}
dir="rtl"
style={{
...S.input,
paddingRight: 30, height: 36, fontSize: 12,
}}
/>
</div>
{/* Grouped permission modules */}
{permissions.length === 0 ? (
<div style={{ textAlign: "center", padding: "2rem", fontSize: 13, color: "var(--color-text-muted)" }}>
جارٍ تحميل الصلاحيات
</div>
) : Object.keys(filteredGrouped).length === 0 ? (
<div style={{ textAlign: "center", padding: "1.5rem", fontSize: 12, color: "var(--color-text-muted)" }}>
لا توجد نتائج لـ &quot;{searchPerm}&quot;
</div>
) : (
<div style={{ <div style={{
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)",
borderRadius: "var(--radius-lg)", padding: "0.875rem", background: "var(--color-surface-muted)",
overflow: "hidden", maxHeight: 280, overflowY: "auto",
}}> }}>
{Object.entries(filteredGrouped).map(([mod, perms], idx) => { {Object.entries(grouped).map(([module, perms]) => (
const allChecked = perms.every(p => selectedPerms.has(p.id)); <PermissionGroup
const someChecked = !allChecked && perms.some(p => selectedPerms.has(p.id)); key={module} module={module} perms={perms}
const isExpanded = expandedModules.has(mod) || !!searchPerm; selected={form.permissionIds} onToggle={togglePerm}
const checkedCount = perms.filter(p => selectedPerms.has(p.id)).length; />
))}
return (
<div key={mod} style={{ borderBottom: idx < Object.keys(filteredGrouped).length - 1 ? "1px solid var(--color-border)" : "none" }}>
{/* Module header row */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.625rem 1rem",
background: isExpanded ? "var(--color-surface-muted)" : "var(--color-surface)",
cursor: "pointer",
transition: "background 150ms",
}}>
{/* Module checkbox (select/deselect all in module) */}
<input
type="checkbox"
checked={allChecked}
ref={el => { if (el) el.indeterminate = someChecked; }}
onChange={() => toggleModule(mod)}
onClick={e => e.stopPropagation()}
style={{ width: 15, height: 15, cursor: "pointer", flexShrink: 0, accentColor: "#2563EB" }}
/>
{/* Module name */}
<button
type="button"
onClick={() => toggleExpandModule(mod)}
style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "space-between",
background: "none", border: "none", cursor: "pointer", padding: 0,
fontFamily: "var(--font-sans)",
}}
>
<span style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-primary)" }}>
{moduleLabel(mod)}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{checkedCount > 0 && (
<span style={{
fontSize: 10, fontWeight: 700,
color: "#2563EB",
background: "var(--color-brand-50)",
border: "1px solid var(--color-brand-200)",
borderRadius: "var(--radius-full)",
padding: "1px 7px",
}}>
{checkedCount}/{perms.length}
</span>
)}
<svg
style={{
width: 14, height: 14,
color: "var(--color-text-muted)",
transform: isExpanded ? "rotate(180deg)" : "none",
transition: "transform 200ms",
}}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
>
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
</button>
</div>
{/* Permissions grid */}
{isExpanded && (
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
gap: "0.5rem",
padding: "0.75rem 1rem 0.875rem",
background: "var(--color-surface)",
}}>
{perms.map(perm => {
const checked = selectedPerms.has(perm.id);
return (
<label
key={perm.id}
style={{
display: "flex", alignItems: "flex-start", gap: 8,
padding: "0.5rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked ? "#EFF6FF" : "var(--color-surface-muted)",
cursor: "pointer",
transition: "all 150ms",
userSelect: "none",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => togglePerm(perm.id)}
style={{ width: 14, height: 14, marginTop: 1, cursor: "pointer", flexShrink: 0, accentColor: "#2563EB" }}
/>
<div>
<p style={{ fontSize: 11, fontWeight: 600, color: checked ? "#1D4ED8" : "var(--color-text-primary)", margin: 0, lineHeight: 1.4 }}>
{perm.name}
</p>
<p style={{ fontSize: 10, color: "var(--color-text-muted)", margin: "2px 0 0", fontFamily: "var(--font-mono)" }}>
{perm.slug}
</p>
</div>
</label>
);
})}
</div>
)}
</div>
);
})}
</div> </div>
)} <p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 6 }}>
</div> {form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
</p>
</div>
)}*/}
{/* ── Action buttons ── */} {/* Actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.25rem", flexShrink: 0 }}> <div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem", flexShrink: 0 }}>
<button <button type="button" onClick={onClose} disabled={saving}
type="button" style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
onClick={onClose}
disabled={saving}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: saving ? "not-allowed" : "pointer",
fontFamily: "var(--font-sans)",
}}
>
إلغاء إلغاء
</button> </button>
<button <button type="submit" disabled={saving}
type="submit" style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
disabled={saving}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "none",
background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13, fontWeight: 700,
color: "#FFF",
cursor: saving ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{saving && <Spinner size="sm" className="text-white" />} {saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إنشاء الدور" : "حفظ التغييرات"} {saving ? "جارٍ الحفظ…" : isNew ? "إنشاء الدور" : "حفظ التغييرات"}
</button> </button>

View File

@@ -1,32 +1,10 @@
"use client"; "use client";
import { Role } from "@/src/types/role";
import { Spinner } from "../UI"; import { Spinner } from "../UI";
import type { Role } from "../../../services/role.service";
// ── 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)",
};
// ── Sub-components ─────────────────────────────────────────────────────────────
// ── Status badge ──────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) { function StatusBadge({ active }: { active: boolean }) {
return ( return (
<span style={{ <span style={{
@@ -44,39 +22,37 @@ function StatusBadge({ active }: { active: boolean }) {
); );
} }
function IconBtn({ // ── Icon button ───────────────────────────────────────────────────────────────
onClick, title, color, bg, borderColor, children, function IconBtn({ onClick, title, color, bg, borderColor, children }: {
}: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode;
onClick: () => void;
title: string;
color: string;
bg: string;
borderColor: string;
children: React.ReactNode;
}) { }) {
return ( return (
<button <button type="button" title={title} aria-label={title}
type="button"
title={title}
aria-label={title}
onClick={e => { e.stopPropagation(); onClick(); }} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ style={{
width: 32, height: 32, width: 32, height: 32, borderRadius: "var(--radius-md)",
borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color,
border: `1px solid ${borderColor}`, cursor: "pointer", display: "inline-flex", alignItems: "center",
background: bg, color, justifyContent: "center", transition: "opacity 150ms",
cursor: "pointer", }}>
display: "inline-flex", alignItems: "center", justifyContent: "center",
transition: "opacity 150ms",
}}
>
{children} {children}
</button> </button>
); );
} }
// ── Props ────────────────────────────────────────────────────────────────────── // ── 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;
@@ -90,26 +66,19 @@ interface RoleTableProps {
onPageChange: (p: number) => void; onPageChange: (p: number) => void;
} }
// ── Component ───────────────────────────────────────────────────────────────── // ── Main table ────────────────────────────────────────────────────────────────
export function RoleTable({ export function RoleTable({
roles, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange, roles, loading, search, page, pages,
onEdit, onDelete, onView, onAddFirst, onPageChange,
}: RoleTableProps) { }: RoleTableProps) {
return ( return (
<div style={cardStyle}> <div style={cardStyle}>
{/* Column headers */} {/* Column headers */}
<div <div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 3fr 1fr 1fr 120px", ...thStyle }}>
dir="rtl" <span>اسم الدور</span>
style={{ <span>الوصف</span>
display: "grid",
gridTemplateColumns: "2fr 2.5fr 1fr 1fr 80px",
...thStyle,
}}
>
<span>الدور</span>
<span>الصلاحيات</span> <span>الصلاحيات</span>
<span>الحالة</span> <span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span> <span style={{ textAlign: "center" }}>إجراءات</span>
</div> </div>
@@ -122,173 +91,87 @@ export function RoleTable({
) : roles.length === 0 ? ( ) : roles.length === 0 ? (
/* Empty state */ /* Empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}> <div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}> <div style={{ fontSize: 40, marginBottom: 12 }}>🛡</div>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها."} <p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: "0 0 8px" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"}
</p>
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: "0 0 16px" }}>
{!search && "ابدأ بإنشاء أول دور في النظام"}
</p> </p>
{!search && ( {!search && (
<button <button type="button" onClick={onAddFirst}
type="button" style={{ fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
onClick={onAddFirst} إضافة أول دور
style={{
marginTop: 12, fontSize: 13, fontWeight: 600,
color: "var(--color-brand-600)",
background: "none", border: "none",
cursor: "pointer", textDecoration: "underline",
}}
>
أضف أول دور
</button> </button>
)} )}
</div> </div>
) : ( ) : (
/* Data rows */ /* Data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}> <ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{roles.map((role, i) => { {roles.map((role, i) => (
const permCount = role.permissions?.length ?? 0; <li key={role.id} style={{
// Show up to 3 permission module badges display: "grid", gridTemplateColumns: "2fr 3fr 1fr 1fr 120px",
const modules = [...new Set(role.permissions?.map(rp => rp.permission.module) ?? [])].slice(0, 3); alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
const extra = (role.permissions?.length ?? 0) - modules.length; borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
return ( fontSize: 13,
<li }}>
key={role.id} <span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{role.name}</span>
onClick={() => onView(role)} <span style={{ color: "var(--color-text-secondary)", fontSize: 12 }}>
style={{ {role.description || <span style={{ color: "var(--color-text-hint)", fontStyle: "italic" }}>لا يوجد وصف</span>}
display: "grid", </span>
gridTemplateColumns: "2fr 2.5fr 1fr 1fr 80px", <span style={{
alignItems: "center", display: "inline-flex", alignItems: "center", justifyContent: "center",
gap: "0.5rem", width: 28, height: 28, borderRadius: "var(--radius-full)",
padding: "0.875rem 1.5rem", background: "#EFF6FF", border: "1px solid #BFDBFE",
borderBottom: "1px solid var(--color-border)", fontSize: 11, fontWeight: 700, color: "#1D4ED8",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", }}>
cursor: "pointer", {role.permissions?.length ?? 0}
fontSize: 13, </span>
}} <StatusBadge active={role.isActive} />
> <div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* Role name + ID */} {/* View */}
<div> <IconBtn title={`عرض ${role.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(role)}>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{role.name} <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
</p> <circle cx="12" cy="12" r="3" />
{role.id && ( </svg>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}> </IconBtn>
{role.id.slice(0, 8)} {/* Edit */}
</p> <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">
</div> <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" />
{/* Permissions preview */} </svg>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.25rem", alignItems: "center" }}> </IconBtn>
{permCount === 0 ? ( {/* Delete */}
<span style={{ fontSize: 11, color: "var(--color-text-hint)" }}>لا توجد صلاحيات</span> <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" />
{modules.map(mod => ( <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<span key={mod} style={{ <path d="M10 11v6M14 11v6" />
fontSize: 10, fontWeight: 600, <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
color: "#374151", </svg>
background: "#F3F4F6", </IconBtn>
border: "1px solid #E5E7EB", </div>
borderRadius: "var(--radius-full)", </li>
padding: "2px 7px", ))}
}}>
{mod}
</span>
))}
{extra > 0 && (
<span style={{
fontSize: 10, fontWeight: 700,
color: "#2563EB",
background: "var(--color-brand-50)",
border: "1px solid var(--color-brand-200)",
borderRadius: "var(--radius-full)",
padding: "2px 7px",
}}>
+{extra}
</span>
)}
<span style={{ fontSize: 10, color: "var(--color-text-muted)", marginRight: 2 }}>
({permCount} صلاحية)
</span>
</>
)}
</div>
{/* Status badge */}
<StatusBadge active={role.isActive !== false} />
{/* Created date */}
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(role.createdAt).toLocaleDateString("ar-SA", {
year: "numeric", month: "short", day: "numeric",
})}
</span>
{/* Actions */}
<div style={{ display: "flex", justifyContent: "center", gap: 6 }}>
<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>
<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> </ul>
)} )}
{/* Pagination */} {/* Pagination */}
{pages > 1 && ( {pages > 1 && (
<div <div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
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)" }}> <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)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
{[ {[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => ( ].map(btn => (
<button <button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
key={btn.label} 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 }}>
type="button"
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} {btn.label}
</button> </button>
))} ))}

View File

@@ -1,57 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import type { RoleNotification } from "../../../hooks/useRole";
interface RoleToastProps {
notification: RoleNotification | null;
}
export function RoleToast({ notification }: RoleToastProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (notification) {
setVisible(true);
} else {
const t = setTimeout(() => setVisible(false), 300);
return () => clearTimeout(t);
}
}, [notification]);
if (!visible && !notification) return null;
const isSuccess = notification?.type === "success";
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "fixed", bottom: 24, left: "50%",
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
zIndex: 9999,
transition: "transform 250ms ease, opacity 250ms ease",
opacity: notification ? 1 : 0,
pointerEvents: "none",
}}
>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: isSuccess ? "#065F46" : "#7F1D1D",
color: "#FFFFFF",
fontSize: 13, fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
maxWidth: "90vw", whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}>
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
<span>{notification?.message}</span>
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
export { RoleFormModal } from "./RoleFormModal"; export { RoleTable } from "./RoleTable";
export { RoleTable } from "./RoleTable"; export { RoleFormModal } from "./RoleFormModal";
export { RoleDetailModal } from "./RoleDetailModal"; export { RoleDetailModal } from "./RoleDetailModal";
export { DeleteRoleModal } from "./DeleteRoleModal"; export { DeleteRoleModal } from "./DeleteRoleModal";
export { RoleToast } from "./RoleToast";

145
src/hooks/useBranch.ts Normal file
View File

@@ -0,0 +1,145 @@
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth";
import { branchService } from "@/src/services/branch.service";
import type {
Branch,
BranchFormData,
TableAction,
TableState,
} from "@/src/types/branch";
import type { ToastNotification } from "@/src/Components/UI";
export type Notification = ToastNotification;
// ── Reducer ──────────────────────────────────────
function tableReducer(s: TableState, a: TableAction): TableState {
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] };
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) };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
// ── first status ─────────────────────────────────────────────────────────
const initialState: TableState = {
branches: [], loading: true, total: 0, pages: 1, error: null,
};
// ── main hook ──────────────────────────────────────────────────────────────
export function useBranches() {
const [state, dispatch] = useReducer(tableReducer, initialState);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [notification, setNotification] = useState<ToastNotification | null>(null);
// notify with auto-dismiss after 4s
const notify = useCallback((n: ToastNotification) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
}, []);
// fetch branches with pagination and search
const loadBranches = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const res = await branchService.getAll(p, q, token);
const payload = res.data ?? res;
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: "عذراً، حدث خطأ أثناء تحميل بيانات الفروع. يرجى المحاولة لاحقاً." });
}
}, []);
// reload branches when page or search changes
useEffect(() => {
loadBranches(page, search);
}, [page, search, loadBranches]);
// create new branch
const createBranch = useCallback(async (data: BranchFormData): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await branchService.create(data, token);
dispatch({ type: "ADD", branch: res.data });
notify({ type: "success", message: "تم إضافة الفرع بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
// update existing branch
const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await branchService.update(id, data, token);
dispatch({ type: "UPDATE", branch: res.data });
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
// delete branch
const deleteBranch = useCallback(async (id: string): Promise<boolean> => {
try {
const token = getStoredToken();
await branchService.delete(id, token);
dispatch({ type: "DELETE", id });
notify({ type: "success", message: "تم حذف الفرع بنجاح." });
return true;
} catch (err) {
const msg = err instanceof Error && err.message
? err.message
: "تعذر الاتصال بالخادم. تحقق من اتصالك بالإنترنت.";
notify({ type: "error", message: msg });
return false;
}
}, [notify]);
const handleSearch = useCallback((q: string) => {
setSearch(q);
setPage(1);
}, []);
const clearError = useCallback(() => dispatch({ type: "CLEAR_ERR" }), []);
return {
//table data and status
...state,
// pagination and search state
page,
search,
setPage,
handleSearch,
clearError,
//main actions
createBranch,
updateBranch,
deleteBranch,
// notifications for success/error messages
notification,
};
}

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { clientAddressService } from "@/src/services/clientAddress.service"; import { clientAddressService } from "@/src/services/clientAddress.service";
@@ -8,12 +7,25 @@ import type {
CreateAddressFormValues, CreateAddressFormValues,
UpdateAddressFormValues, UpdateAddressFormValues,
} from "@/src/validations/client_address.validator"; } from "@/src/validations/client_address.validator";
import { AddressTableAction, AddressTableState } from "../types/client";
// ── Notification ─────────────────────────────────────────────────────────── import type {
export interface Notification { AddressTableAction,
type: "success" | "error"; AddressTableState,
message: string; ClientAddress,
} from "../types/client_adresses";
import { Notification } from "../types/notif";
function normalizeAddress(raw: any): ClientAddress {
return {
...raw,
id: raw.id ?? raw._id,
};
}
function normalizeAddresses(rawList: any[]): ClientAddress[] {
return rawList.map(normalizeAddress);
} }
// ── Reducer ──────────────────────────────────────────────────────────────── // ── Reducer ────────────────────────────────────────────────────────────────
@@ -57,13 +69,12 @@ export function useClientAddresses(clientId: string) {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const [notification, setNotification] = useState<Notification | null>(null); const [notification, setNotification] = useState<Notification | null>(null);
/** Show a toast for 4 seconds then auto-dismiss. */
const notify = useCallback((n: Notification) => { const notify = useCallback((n: Notification) => {
setNotification(n); setNotification(n);
setTimeout(() => setNotification(null), 4000); setTimeout(() => setNotification(null), 4000);
}, []); }, []);
// ── Fetch addresses ────────────────────────────────────────────────────── // ── Fetch ────────────────────────────────────────────────────────────────
const loadAddresses = useCallback(async () => { const loadAddresses = useCallback(async () => {
if (!clientId) return; if (!clientId) return;
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });
@@ -71,9 +82,10 @@ export function useClientAddresses(clientId: string) {
const token = getStoredToken(); const token = getStoredToken();
const res = await clientAddressService.getAll(clientId, token); const res = await clientAddressService.getAll(clientId, token);
const payload = (res as any).data ?? res; const payload = (res as any).data ?? res;
const rawList = Array.isArray(payload) ? payload : (payload.data ?? []);
dispatch({ dispatch({
type: "LOAD_OK", type: "LOAD_OK",
addresses: Array.isArray(payload) ? payload : (payload.data ?? []), addresses: normalizeAddresses(rawList),
}); });
} catch { } catch {
dispatch({ dispatch({
@@ -93,7 +105,7 @@ export function useClientAddresses(clientId: string) {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const res = await clientAddressService.create(clientId, data, token); const res = await clientAddressService.create(clientId, data, token);
dispatch({ type: "ADD", address: res.data }); dispatch({ type: "ADD", address: normalizeAddress(res.data) });
notify({ type: "success", message: "تم إضافة العنوان بنجاح." }); notify({ type: "success", message: "تم إضافة العنوان بنجاح." });
return true; return true;
} catch (err) { } catch (err) {
@@ -113,7 +125,7 @@ export function useClientAddresses(clientId: string) {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const res = await clientAddressService.update(clientId, id, data, token); const res = await clientAddressService.update(clientId, id, data, token);
dispatch({ type: "UPDATE", address: res.data }); dispatch({ type: "UPDATE", address: normalizeAddress(res.data) });
notify({ type: "success", message: "تم تحديث العنوان." }); notify({ type: "success", message: "تم تحديث العنوان." });
return true; return true;
} catch (err) { } catch (err) {
@@ -147,29 +159,6 @@ export function useClientAddresses(clientId: string) {
[clientId, notify], [clientId, notify],
); );
// ── SET PRIMARY ──────────────────────────────────────────────────────────
const makePrimary = useCallback(
async (id: string): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await clientAddressService.setPrimary(clientId, id, token);
// Backend demotes all others — reload to sync
dispatch({ type: "UPDATE", address: res.data });
// Reload to reflect the backend's cascade demotions
await loadAddresses();
notify({ type: "success", message: "تم تعيين العنوان الرئيسي." });
return true;
} catch (err) {
notify({
type: "error",
message: err instanceof Error ? err.message : "تعذّر تعيين العنوان الرئيسي.",
});
return false;
}
},
[clientId, notify, loadAddresses],
);
return { return {
...state, ...state,
notification, notification,
@@ -177,7 +166,6 @@ export function useClientAddresses(clientId: string) {
createAddress, createAddress,
updateAddress, updateAddress,
deleteAddress, deleteAddress,
makePrimary,
reload: loadAddresses, reload: loadAddresses,
}; };
} }

View File

@@ -1,45 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { clientService } from "@/src/services/client.service";
import type { Order } from "@/src/types/order";
interface State {
orders: Order[];
error: string | null;
loading: boolean;
}
/**
* Fetches all orders for a client session.
*
* @example
* const { orders, loading, error } = useClientOrders(session.id);
*/
export function useClientOrders(clientId: string): State {
const [state, setState] = useState<State>({
orders: [], error: null, loading: true,
});
useEffect(() => {
if (!clientId) {
setState({ orders: [], error: null, loading: false });
return;
}
let cancelled = false;
clientService
.getOrders(clientId)
.then(orders => {
if (!cancelled) setState({ orders, error: null, loading: false });
})
.catch((err: Error) => {
if (!cancelled) setState({ orders: [], error: err.message, loading: false });
});
return () => { cancelled = true; };
}, [clientId]);
return state;
}

View File

@@ -9,12 +9,9 @@ import type {
ClientTableAction, ClientTableAction,
ClientTableState, ClientTableState,
} from "@/src/types/client"; } from "@/src/types/client";
import { Notification } from "../types/notif";
// ── Notification (mirrors useUsers Notification) ───────────────────────────
export interface Notification {
type: "success" | "error";
message: string;
}
// ── Reducer ──────────────────────────────────────────────────────────────── // ── Reducer ────────────────────────────────────────────────────────────────
function reducer(s: ClientTableState, a: ClientTableAction): ClientTableState { function reducer(s: ClientTableState, a: ClientTableAction): ClientTableState {

View File

@@ -3,14 +3,12 @@
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useState } from "react";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { roleService } from "@/src/services/role.service"; import { roleService } from "@/src/services/role.service";
import type { Role, RoleFormData, Permission } from "@/src/services/role.service"; import { Notification } from "../types/notif";
import { Permission, Role, RoleFormData } from "../types/role";
// ── Notification ──────────────────────────────────────────────────────────────
export interface RoleNotification {
type: "success" | "error";
message: string;
}
// ── Table state / reducer ───────────────────────────────────────────────────── // ── Table state / reducer ─────────────────────────────────────────────────────
@@ -76,11 +74,11 @@ export function useRoles() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [permissions, setPermissions] = useState<Permission[]>([]); const [permissions, setPermissions] = useState<Permission[]>([]);
const [notification, setNotification] = useState<RoleNotification | null>( const [notification, setNotification] = useState<Notification | null>(
null, null,
); );
const notify = useCallback((n: RoleNotification) => { const notify = useCallback((n: Notification) => {
setNotification(n); setNotification(n);
setTimeout(() => setNotification(null), 4000); setTimeout(() => setNotification(null), 4000);
}, []); }, []);
@@ -144,15 +142,6 @@ export function useRoles() {
const res = await roleService.create(data, token); const res = await roleService.create(data, token);
const role = (res as unknown as { data: Role }).data; const role = (res as unknown as { data: Role }).data;
// Assign permissions sequentially (backend handles individually)
for (const pid of data.permissionIds) {
try {
await roleService.assignPermission(role.id, pid, token);
} catch {
/* non-fatal: skip individual failures */
}
}
// Re-fetch to get fresh data with permissions attached // Re-fetch to get fresh data with permissions attached
await loadRoles(page, search); await loadRoles(page, search);
notify({ notify({
@@ -190,25 +179,11 @@ export function useRoles() {
(p) => !data.permissionIds.includes(p), (p) => !data.permissionIds.includes(p),
); );
for (const pid of toAdd) {
try {
await roleService.assignPermission(id, pid, token);
} catch {
/* skip */
}
}
for (const pid of toRemove) {
try {
await roleService.removePermission(id, pid, token);
} catch {
/* skip */
}
}
await loadRoles(page, search); await loadRoles(page, search);
notify({ notify({
type: "success", type: "success",
message: "تم تحديث الدور والصلاحيات بنجاح.", message: "تم تحديث الدور بنجاح.",
}); });
return true; return true;
} catch (err) { } catch (err) {
@@ -240,25 +215,6 @@ export function useRoles() {
}, },
[notify], [notify],
); );
const updateRolePermissionsBulk = useCallback(
async (roleId: string, permissionIds: string[]): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await roleService.setPermissions(roleId, permissionIds, token);
const updated = (res as unknown as { data: Role }).data;
dispatch({ type: "UPDATE", role: updated });
notify({ type: "success", message: "تم تحديث صلاحيات الدور بنجاح." });
return true;
} catch (err) {
notify({
type: "error",
message: err instanceof Error ? err.message : "تعذّر تحديث صلاحيات الدور.",
});
return false;
}
},
[notify],
);
const handleSearch = useCallback((q: string) => { const handleSearch = useCallback((q: string) => {
@@ -277,7 +233,6 @@ const updateRolePermissionsBulk = useCallback(
createRole, createRole,
updateRole, updateRole,
deleteRole, deleteRole,
updateRolePermissionsBulk,
notification, notification,
}; };

View File

@@ -1,10 +1,7 @@
import { post } from "./api"; import { post } from "./api";
import type { LoginResponse } from "@/src/types/auth"; import type { LoginPayload, LoginResponse } from "@/src/types/auth";
export interface LoginPayload {
identity: string;
password: string;
}
export const authService = { export const authService = {
/** /**

View File

@@ -0,0 +1,56 @@
import { get, post, patch, del } from "./api";
import type { ApiListResponse, Branch, BranchDetail, BranchFormData, BranchResponse } from "@/src/types/branch";
/** Building a query string to fetch branches with search and pagination*/
function buildBranchesQuery(page: number, search: string): string {
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
}
export const branchService = {
/** Bring up the list of branches with the ability to search and browse between pages*/
getAll: (page: number, search: string, token: string | null) =>
get<ApiListResponse<Branch>>(`branches${buildBranchesQuery(page, search)}`, token),
/** create new branch*/
create: (data: BranchFormData, token: string | null) =>
post<BranchResponse>("branches", buildPayload(data), token),
/** update branch*/
update: (id: string, data: Partial<BranchFormData>, token: string | null) =>
patch<BranchResponse>(`branches/${id}`, buildPayload(data), token),
/**delete branch*/
delete: (id: string, token: string | null) => del<void>(`branches/${id}`, token),
/** get branch by id*/
getById: (id: string, token: string | null) =>
get<{ data: BranchDetail }>(`branches/${id}`, token),
};
/** Converting the form data into a payload suitable for the API*/
function buildPayload(
data: Partial<BranchFormData>,
): Record<string, string | number> {
const payload: Record<string, string | number> = {};
if (data.name !== undefined) payload.name = data.name.trim();
if (data.email) payload.email = data.email.trim();
if (data.phone) payload.phone = data.phone.trim();
if (data.country) payload.country = data.country.trim();
if (data.city !== undefined) payload.city = data.city.trim();
if (data.state) payload.state = data.state.trim();
if (data.district) payload.district = data.district.trim();
if (data.street !== undefined) payload.street = data.street.trim();
if (data.buildingNo) payload.buildingNo = data.buildingNo.trim();
if (data.unitNo) payload.unitNo = data.unitNo.trim();
if (data.zipCode) payload.zipCode = data.zipCode.trim();
if (data.latitude && !isNaN(Number(data.latitude))) payload.latitude = Number(data.latitude);
if (data.longitude && !isNaN(Number(data.longitude))) payload.longitude = Number(data.longitude);
return payload;
}

View File

@@ -1,12 +1,10 @@
// Mirrors user.service.ts exactly: token parameter, buildPayload, buildQuery. // Mirrors user.service.ts exactly: token parameter, buildPayload, buildQuery.
import { get, post, put, del } from "./api"; import { get, post, put, del } from "./api";
import type { ApiResponse, ApiListResponse, Client, ClientFormData } from "@/src/types/client"; import type { ApiResponse, ApiListResponse, Client, ClientFormData, ClientResponse } from "@/src/types/client";
/** نوع الاستجابة لعملية واحدة على عميل */
export interface ClientResponse {
data: Client;
}
/** بناء query string لجلب العملاء مع البحث والصفحات */ /** بناء query string لجلب العملاء مع البحث والصفحات */
function buildClientsQuery(page: number, search: string): string { function buildClientsQuery(page: number, search: string): string {

View File

@@ -1,86 +1,83 @@
import { get, post, put, patch, del } from "./api"; import { get, post, patch, del } from "./api";
import type { ApiResponse, ClientAddress } from "@/src/types/client"; import type { ApiResponse } from "@/src/types/client";
import type { ClientAddress } from "@/src/types/client_adresses";
import type { import type {
CreateAddressFormValues, CreateAddressFormValues,
UpdateAddressFormValues, UpdateAddressFormValues,
} from "@/src/validations/client_address.validator"; } from "@/src/validations/client_address.validator";
// FIX: if clientId is undefined/empty, this used to silently build a broken
// URL like "client/undefined/addresses" which 404s with no clear error.
// Now it throws a clear error pointing at the real problem (missing clientId). const collectionBase = (clientId: string) => {
const base = (clientId: string) => {
if (!clientId) { if (!clientId) {
throw new Error("clientAddressService: clientId is required but was empty/undefined. Check useParams() key matches your route folder name."); throw new Error(
"clientAddressService: clientId is required but was empty/undefined. Check useParams() key matches your route folder name."
);
} }
return `client/${clientId}/addresses`; return `client/${clientId}/addresses`;
}; };
// FIX: same idea as base() above — fail clearly if addressId is missing, const addressBase = (addressId: string) => {
// instead of building "client/123/addresses/undefined"
const withAddressId = (clientId: string, addressId: string) => {
if (!addressId) { if (!addressId) {
throw new Error("clientAddressService: addressId is required but was empty/undefined."); throw new Error("clientAddressService: addressId is required but was empty/undefined.");
} }
return `${base(clientId)}/${addressId}`; return `addresses/${addressId}`;
}; };
export const clientAddressService = { export const clientAddressService = {
/** Get all addresses for a client */ /** Get all addresses for a client — list stays scoped under the client */
getAll: (clientId: string, token: string | null) => getAll: (clientId: string, token: string | null) =>
get<ApiResponse<ClientAddress[]>>(base(clientId), token), get<ApiResponse<ClientAddress[]>>(collectionBase(clientId), token),
/** Get a single address by id */ /**
* Get a single address by id.
*/
getById: (clientId: string, addressId: string, token: string | null) => getById: (clientId: string, addressId: string, token: string | null) =>
get<ApiResponse<ClientAddress>>(withAddressId(clientId, addressId), token), get<ApiResponse<ClientAddress>>(addressBase(addressId), token),
/** Create a new address */ /** Create a new address — list stays scoped under the client */
create: ( create: (
clientId: string, clientId: string,
data: CreateAddressFormValues, data: CreateAddressFormValues,
token: string | null token: string | null
) => ) =>
post<ApiResponse<ClientAddress>>(base(clientId), buildPayload(data), token), post<ApiResponse<ClientAddress>>(collectionBase(clientId), buildPayload(data), token),
/** Update an existing address */ /**
* Update an existing address.
*/
update: ( update: (
clientId: string, clientId: string,
addressId: string, addressId: string,
data: UpdateAddressFormValues, data: UpdateAddressFormValues,
token: string | null token: string | null
) => ) =>
put<ApiResponse<ClientAddress>>( patch<ApiResponse<ClientAddress>>(
withAddressId(clientId, addressId), addressBase(addressId),
buildPayload(data), buildPayload(data),
token token
), ),
/** Delete an address */ /**
* Delete an address.
*/
delete: (clientId: string, addressId: string, token: string | null) => delete: (clientId: string, addressId: string, token: string | null) =>
del<void>(withAddressId(clientId, addressId), token), del<void>(addressBase(addressId), token),
/** Promote an address to primary (backend demotes all others) */
setPrimary: (clientId: string, addressId: string, token: string | null) =>
patch<ApiResponse<ClientAddress>>(
`${withAddressId(clientId, addressId)}/primary`,
{},
token
),
}; };
/**
* Maps the validated form shape (nested) directly to the API payload.
* FIX 1: No more .trim() calls on flat fields that no longer exist —
* the validator already trims every string field before we reach here.
*/
function buildPayload( function buildPayload(
data: CreateAddressFormValues | UpdateAddressFormValues data: CreateAddressFormValues | UpdateAddressFormValues
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
label: data.label, label: data.label,
branchName: data.branchName, branchName: data.branchName,
isPrimary: data.isPrimary,
contactPerson: data.contactPerson, contactPerson: data.contactPerson,
details: data.details, details: data.details,
location: data.location, location: data.location,
}; };
} }

View File

@@ -1,50 +1,12 @@
import { requestJson } from "@/src/lib/api"; import { ApiPaginatedResponse, PermissionsResponse, Role, RoleFormData, RoleResponse } from "../types/role";
import { get, post, put, del, patch } from "./api"; import { get, post, put, del, patch } from "./api";
// ── Types ─────────────────────────────────────────────────────────────────────
export interface Permission {
id: string;
name: string;
slug: string;
module: string;
}
export interface Role {
id: string;
name: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
permissions?: Array<{
permission: Permission;
}>;
}
export interface RoleFormData {
name: string;
description: string;
permissionIds: string[];
}
export interface ApiPaginatedResponse<T> {
data: {
data: T[];
pagination?: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
export interface RoleResponse {
data: Role;
}
export interface PermissionsResponse {
data: {
premissions: Permission[];
};
}
/** Build query string for paginated role listing */ /** Build query string for paginated role listing */
function buildRolesQuery(page: number, search: string): string { function buildRolesQuery(page: number, search: string): string {
@@ -78,18 +40,5 @@ export const roleService = {
getPermissions: (token: string | null) => getPermissions: (token: string | null) =>
get<PermissionsResponse>("premission?limit=200", token), get<PermissionsResponse>("premission?limit=200", token),
/** Assign a single permission to a role */
assignPermission: (roleId: string, permissionId: string, token: string | null) =>
post<unknown>(`role/${roleId}/permissions`, { permissionId }, token),
/** Remove a permission from a role */
removePermission: (roleId: string, permissionId: string, token: string | null) =>
del<unknown>(`role/${roleId}/permissions/${permissionId}`, token),
/** Atomically replace all permissions assigned to a role */
setPermissions: (roleId: string, permissionIds: string[], token: string | null) =>
patch<{ data: Role }>(`role/${roleId}/permissions`, {
method: "PATCH",
body: JSON.stringify({ permissionIds }),
}, token),
}; };

View File

@@ -1,35 +1,33 @@
// خدمة المستخدمين — كل نداءات الـ API الخاصة بالمستخدمين تكون هنا فقط
import { get, post, put, del } from "./api"; import { get, post, put, del } from "./api";
import type { ApiListResponse, User, UserFormData } from "@/src/types/user"; import type { ApiListResponse, User, UserFormData, UserResponse } from "@/src/types/user";
import type { Role } from "@/src/types/role"; import type { Role } from "@/src/types/role";
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
/** نوع الاستجابة لعملية واحدة على مستخدم */
export interface UserResponse {
data: User;
}
/** بناء query string لجلب المستخدمين مع البحث والصفحات */
/**Building a query string to fetch users with search and pagination*/
function buildUsersQuery(page: number, search: string): string { function buildUsersQuery(page: number, search: string): string {
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`; return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
} }
export const userService = { export const userService = {
/** جلب قائمة المستخدمين مع إمكانية البحث والتصفح بين الصفحات */ /** Fetching the user list with the ability to search and browse through pages*/
getAll: (page: number, search: string, token: string | null) => getAll: (page: number, search: string, token: string | null) =>
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token), get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
/** إنشاء مستخدم جديد */ /** create new account*/
create: ( create: (
data: Omit<UserFormData, "password"> & { password: string }, data: Omit<UserFormData, "password"> & { password: string },
token: string | null, token: string | null,
) => post<UserResponse>("users", buildPayload(data, true), token), ) => post<UserResponse>("users", buildPayload(data, true), token),
/** تعديل بيانات مستخدم موجود */ /**Edit existing user info*/
update: (id: string, data: UserFormData, token: string | null) => update: (id: string, data: UserFormData, token: string | null) =>
put<UserResponse>(`users/${id}`, buildPayload(data, false), token), put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
/** حذف مستخدم */ /** delete user*/
delete: (id: string, token: string | null) => del<void>(`users/${id}`, token), delete: (id: string, token: string | null) => del<void>(`users/${id}`, token),
//get role //get role
getRoles: (token: string | null) => getRoles: (token: string | null) =>
@@ -53,7 +51,7 @@ export const userService = {
}>(`users/${id}`, token), }>(`users/${id}`, token),
}; };
/** تحويل بيانات النموذج إلى payload مناسب للـ API */ /** Converting the form data into a payload suitable for the API */
function buildPayload( function buildPayload(
data: UserFormData, data: UserFormData,
isNew: boolean, isNew: boolean,

View File

@@ -13,4 +13,8 @@ export interface AuthUser {
export interface LoginResponse { export interface LoginResponse {
token: string; token: string;
user: AuthUser; user: AuthUser;
}
export interface LoginPayload {
identity: string;
password: string;
} }

View File

@@ -1,4 +1,83 @@
export interface Branch { export interface Branch {
id: string; id: string;
name: string; name: string;
email?: string | null;
phone?: string | null;
country: string;
city: string;
state?: string | null;
district?: string | null;
street: string;
buildingNo?: string | null;
unitNo?: string | null;
zipCode?: string | null;
latitude?: number | null;
longitude?: number | null;
isActive: boolean;
createdAt: string;
}
export interface BranchResponse {
data: Branch;
}
export interface BranchFormData {
name: string;
email: string;
phone: string;
country: string;
city: string;
state: string;
district: string;
street: string;
buildingNo: string;
unitNo: string;
zipCode: string;
latitude: string;
longitude: string;
}
export interface FormErrors {
name?: string;
email?: string;
phone?: string;
country?: string;
city?: string;
state?: string;
district?: string;
street?: string;
buildingNo?: string;
unitNo?: string;
zipCode?: string;
latitude?: string;
longitude?: string;
}
export interface ApiListResponse<T> {
data: {
data: T[];
pagination: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
export type TableState = {
branches: Branch[];
loading: boolean;
total: number;
pages: number;
error: string | null;
};
export type TableAction =
| { 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" };
export interface BranchDetail extends Branch {
updatedAt: string;
isDeleted: boolean;
deletedAt: string | null;
} }

View File

@@ -1,5 +1,20 @@
// ─── Domain Types ───────────────────────────────────────────────────────────── // ─── Domain Types ─────────────────────────────────────────────────────────────
export type {
ClientAddress,
CreateClientAddressPayload,
UpdateClientAddressPayload,
ClientAddressFormData,
ClientAddressFormErrors,
AddressTableState,
AddressTableAction,
} from "./client_adresses";
import type { ClientAddress } from "./client_adresses";
export interface ClientResponse {
data: Client;
}
export interface Client { export interface Client {
id: string; id: string;
name: string; name: string;
@@ -12,69 +27,13 @@ export interface Client {
addresses?: ClientAddress[]; addresses?: ClientAddress[];
} }
// FIX: this interface was flat (street, city, state... directly on the object)
// but the real API / form / validator use a NESTED shape (details, contactPerson,
// location). Updated to match what Addressformmodal.tsx actually reads
// (editAddress?.details?.city, editAddress?.contactPerson?.phone, etc).
export interface ClientAddress {
id: string;
clientId: string;
label: string; // e.g. "Billing", "Shipping", "HQ"
// was missing before — needed because the form reads editAddress?.branchName
branchName: string | null;
// was missing before — needed because the form reads editAddress?.contactPerson?.name/phone
contactPerson?: {
name?: string;
phone?: string;
};
// was flat (street/city/state/postalCode/country) — now nested under "details"
// to match details.city, details.street, etc. used in the form and validator
details: {
country: string;
city: string;
state?: string;
district?: string;
street: string;
buildingNo?: string;
unitNo?: string;
additionalNo?: string;
zipCode?: string;
apartment?: string;
};
// was missing before — needed because the form reads
// editAddress?.location?.coordinates
location: {
coordinates: [number, number]; // [longitude, latitude]
};
isPrimary: boolean;
isValidated?: boolean; // backend-only flag used by updateAddressSchema
createdAt: string;
updatedAt: string;
}
// ─── Request / Response shapes ──────────────────────────────────────────────── // ─── Request / Response shapes ────────────────────────────────────────────────
export type CreateClientPayload = Omit<Client, "id" | "createdAt" | "updatedAt" | "addresses">; export type CreateClientPayload = Omit<Client, "id" | "createdAt" | "updatedAt" | "addresses">;
export type UpdateClientPayload = Partial<CreateClientPayload>; export type UpdateClientPayload = Partial<CreateClientPayload>;
export type CreateClientAddressPayload = Omit<
ClientAddress,
"id" | "createdAt" | "updatedAt"
>;
export type UpdateClientAddressPayload = Partial<CreateClientAddressPayload>;
// ─── Form & Validation ──────────────────────────────────────────────────────── // ─── Form & Validation ────────────────────────────────────────────────────────
// FIX: taxId and notes are optional in the actual client form (yup .optional()),
// but this interface marked them as required strings. That mismatch is what
// caused the "Property 'taxId' is missing" type error on onSubmit.
// NOTE: if your form schema truly requires these fields, do the opposite —
// remove the "?" here AND make sure the yup schema uses .required() too.
export interface ClientFormData { export interface ClientFormData {
name: string; name: string;
email: string; email: string;
@@ -91,26 +50,7 @@ export interface ClientFormErrors {
notes?: string; notes?: string;
} }
export interface ClientAddressFormData { // ─── Table State / Reducer ─────────────────────────────────────────────────────
label: string;
street: string;
city: string;
state: string;
postalCode: string;
country: string;
isPrimary: boolean;
}
export interface ClientAddressFormErrors {
label?: string;
street?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}
// ─── Table State / Reducer (mirrors user.ts TableState / TableAction) ─────────
export type ClientTableState = { export type ClientTableState = {
clients: Client[]; clients: Client[];
@@ -129,21 +69,6 @@ export type ClientTableAction =
| { type: "DELETE"; id: string } | { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" }; | { type: "CLEAR_ERR" };
export type AddressTableState = {
addresses: ClientAddress[];
loading: boolean;
error: string | null;
};
export type AddressTableAction =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; addresses: ClientAddress[] }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; address: ClientAddress }
| { type: "UPDATE"; address: ClientAddress }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };
// ─── Generic API wrapper ────────────────────────────────────────────────────── // ─── Generic API wrapper ──────────────────────────────────────────────────────
export interface ApiResponse<T> { export interface ApiResponse<T> {

View File

@@ -0,0 +1,83 @@
// ─── Address Domain Types ──────────────────────────────────────────────────
export interface ClientAddress {
id: string;
clientId: string;
label: string;
branchName: string | null;
contactPerson?: {
name?: string;
phone?: string;
};
details: {
country: string;
city: string;
state?: string;
district?: string;
street: string;
buildingNo?: string;
unitNo?: string;
additionalNo?: string;
zipCode?: string;
apartment?: string;
};
location: {
coordinates: [number, number];
};
isPrimary: boolean;
isValidated?: boolean;
createdAt: string;
updatedAt: string;
}
// ─── Request / Response shapes ─────────────────────────────────────────────
export type CreateClientAddressPayload = Omit<
ClientAddress,
"id" | "createdAt" | "updatedAt"
>;
export type UpdateClientAddressPayload = Partial<CreateClientAddressPayload>;
// ─── Legacy flat form shape ────────────────────────────────────────────────
export interface ClientAddressFormData {
label: string;
street: string;
city: string;
state: string;
postalCode: string;
country: string;
isPrimary: boolean;
}
export interface ClientAddressFormErrors {
label?: string;
street?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}
// ─── Table State / Reducer ────────────────────────────────────────────────
export type AddressTableState = {
addresses: ClientAddress[];
loading: boolean;
error: string | null;
};
// SET_PRIMARY_LOCAL removed — primary is now a backend-only concept
export type AddressTableAction =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; addresses: ClientAddress[] }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; address: ClientAddress }
| { type: "UPDATE"; address: ClientAddress }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };

View File

@@ -124,4 +124,5 @@ export const DRIVER_CARD_TYPE_MAP: Record<DriverCardType, string> = {
Seasonal: "موسمية", Seasonal: "موسمية",
Annual: "سنوية", Annual: "سنوية",
Restricted: "مقيدة", Restricted: "مقيدة",
}; };

4
src/types/notif.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Notification {
type: "success" | "error";
message: string;
}

View File

@@ -23,7 +23,11 @@ export interface OrderAddress {
coordinates?: [number, number]; // [longitude, latitude] coordinates?: [number, number]; // [longitude, latitude]
}; };
} }
export interface State {
orders: Order[];
error: string | null;
loading: boolean;
}
export interface Order { export interface Order {
id: string; id: string;
shipmentNumber: string; shipmentNumber: string;

View File

@@ -1,4 +1,43 @@
export interface Role { export interface Role {
id: string; id: string;
name: string; name: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
permissions?: Array<{
permission: Permission;
}>;
}
export interface RoleFormData {
name: string;
description: string;
permissionIds: string[];
}
export interface ApiPaginatedResponse<T> {
data: {
data: T[];
pagination?: { total: number; page: number; pages: number };
meta?: { total: number; pages: number };
};
}
export interface RoleResponse {
data: Role;
}
export interface PermissionsResponse {
data: {
premissions: Permission[];
};
}
//permissions
export interface Permission {
id: string;
name: string;
slug: string;
module: string;
} }

View File

@@ -153,4 +153,5 @@ export const TRIP_STATUS_MAP: Record<
InProgress: { label: "جارية", color: "#92400E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" }, InProgress: { label: "جارية", color: "#92400E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
Completed: { label: "مكتملة", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" }, Completed: { label: "مكتملة", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
Cancelled: { label: "ملغاة", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" }, Cancelled: { label: "ملغاة", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" },
}; };

View File

@@ -9,7 +9,9 @@ export interface User {
role?: { id?: string; name: string }; role?: { id?: string; name: string };
branch?: { id?: string; name: string }; branch?: { id?: string; name: string };
} }
export interface UserResponse {
data: User;
}
export interface UserFormData { export interface UserFormData {
name: string; name: string;
email: string; email: string;

View File

@@ -0,0 +1,152 @@
import * as yup from "yup";
// ── Phone regex — same as backend ─────────────────────────────────────────────
const SAUDI_PHONE_RE = /^(\+966|966|0)?5[0-9]{8}$/;
// ── Create schema ─────────────────────────────────────────────────────────────
// Mirrors branch_validator.js createBranchSchema (Zod) field-for-field.
export const createBranchSchema = yup.object({
name: yup
.string()
.required("اسم الفرع مطلوب")
.min(2, "اسم الفرع يجب أن يكون حرفين على الأقل"),
email: yup
.string()
.email("البريد الإلكتروني غير صالح")
.optional(),
phone: yup
.string()
.matches(SAUDI_PHONE_RE, { message: "رقم الجوال غير صالح — يجب أن يكون رقم سعودي صحيح", excludeEmptyString: true })
.optional(),
country: yup
.string()
.optional(),
city: yup
.string()
.required("المدينة مطلوبة"),
state: yup
.string()
.optional(),
district: yup
.string()
.optional(),
street: yup
.string()
.required("الشارع مطلوب"),
buildingNo: yup
.string()
.optional(),
unitNo: yup
.string()
.optional(),
zipCode: yup
.string()
.optional(),
latitude: yup
.string()
.test("is-number", "خط العرض غير صالح", v => !v || !isNaN(Number(v)))
.optional(),
longitude: yup
.string()
.test("is-number", "خط الطول غير صالح", v => !v || !isNaN(Number(v)))
.optional(),
});
// ── Update schema ─────────────────────────────────────────────────────────────
// Mirrors updateBranchSchema = createBranchSchema.partial().extend({ isActive })
export const updateBranchSchema = yup.object({
name: yup
.string()
.min(2, "اسم الفرع يجب أن يكون حرفين على الأقل")
.optional(),
email: yup
.string()
.email("البريد الإلكتروني غير صالح")
.optional(),
phone: yup
.string()
.matches(SAUDI_PHONE_RE, { message: "رقم الجوال غير صالح — يجب أن يكون رقم سعودي صحيح", excludeEmptyString: true })
.optional(),
country: yup
.string()
.optional(),
city: yup
.string()
.optional(),
state: yup
.string()
.optional(),
district: yup
.string()
.optional(),
street: yup
.string()
.optional(),
buildingNo: yup
.string()
.optional(),
unitNo: yup
.string()
.optional(),
zipCode: yup
.string()
.optional(),
latitude: yup
.string()
.test("is-number", "خط العرض غير صالح", v => !v || !isNaN(Number(v)))
.optional(),
longitude: yup
.string()
.test("is-number", "خط الطول غير صالح", v => !v || !isNaN(Number(v)))
.optional(),
isActive: yup.boolean().optional(),
});
// ── Infer form error shape ────────────────────────────────────────────────────
export type BranchSchemaErrors = Partial<
Record<
| "name"
| "email"
| "phone"
| "country"
| "city"
| "state"
| "district"
| "street"
| "buildingNo"
| "unitNo"
| "zipCode"
| "latitude"
| "longitude"
| "isActive",
string
>
>;

View File

@@ -0,0 +1,28 @@
import * as yup from "yup";
export const createRoleSchema = yup.object({
name: yup
.string()
.required("اسم الدور مطلوب")
.min(2, "الاسم يجب أن يكون حرفين على الأقل"),
description: yup
.string()
.min(5, "الوصف يجب أن يكون 5 أحرف على الأقل")
.optional(),
permissionIds: yup.array(yup.string().required()).default([]),
});
export const updateRoleSchema = yup.object({
name: yup
.string()
.required("اسم الدور مطلوب")
.min(2, "الاسم يجب أن يكون حرفين على الأقل"),
description: yup
.string()
.min(5, "الوصف يجب أن يكون 5 أحرف على الأقل")
.optional(),
isActive: yup.boolean().optional(),
permissionIds: yup.array(yup.string().required()).default([]),
});
export type RoleFormErrors = Partial<Record<"name" | "description", string>>;