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:
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 [search, setSearch] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [formTarget, setFormTarget] = useState<Branch | null | false>(false);
|
const [formTarget, setFormTarget] = useState<Branch | null | false>(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
|
||||||
|
// ID of branch whose detail modal is open; null = closed
|
||||||
|
const [viewBranchId, setViewBranchId] = useState<string | null>(null);
|
||||||
|
// Local submitting flag shown in DeleteConfirmModal spinner
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [notification, setNotification] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
|
||||||
|
|
||||||
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();
|
const ok = await deleteBranch(deleteTarget.id);
|
||||||
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);
|
setDeleting(false);
|
||||||
}
|
if (ok) setDeleteTarget(null);
|
||||||
}, [deleteTarget, notify]);
|
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
|
||||||
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.2em",
|
|
||||||
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
|
// app/dashboard/clients/[clientId]/addresses/page.tsx
|
||||||
|
//
|
||||||
|
// ── Refactor notes ──────────────────────────────────────────────────────────
|
||||||
|
// • Address cards are no longer clickable / navigable. Removed the `onView`
|
||||||
|
// prop, the card's onClick handler, and the pointer cursor + hover styles
|
||||||
|
// that implied the card itself was a link.
|
||||||
|
// • Each AddressCard now renders full address details inline (street, city,
|
||||||
|
// state, district, building/unit/additional no., zip code, country, and
|
||||||
|
// contact person), instead of a 3-line summary that deferred to the
|
||||||
|
// AddressDetail page.
|
||||||
|
// • Removed all routing to the address detail route.
|
||||||
|
// • Deleted as part of this refactor (no longer referenced anywhere):
|
||||||
|
// - app/dashboard/clients/[clientId]/addresses/[addressId]/page.tsx
|
||||||
|
// - app/dashboard/clients/[clientId]/addresses/[addressesId]/page.tsx
|
||||||
|
// - src/Components/Client_Adress/AddressDetails.tsx
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
// ── UI components (canonical barrel — no old Client/Toast) ─────────────────
|
|
||||||
import { Alert, Toast } from "@/src/Components/UI";
|
import { Alert, Toast } from "@/src/Components/UI";
|
||||||
|
|
||||||
// ── Hooks & services ───────────────────────────────────────────────────────
|
|
||||||
import { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
import { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
||||||
import { clientService } from "@/src/services/client.service";
|
import { clientService } from "@/src/services/client.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
import type { Client, ClientFormData } from "@/src/types/client";
|
||||||
import type {
|
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||||
Client,
|
|
||||||
ClientAddress,
|
|
||||||
ClientAddressFormData,
|
|
||||||
ClientFormData,
|
|
||||||
} from "@/src/types/client";
|
|
||||||
|
|
||||||
// ── Client-specific modals ─────────────────────────────────────────────────
|
|
||||||
import { AddressFormModal, DeleteConfirmModal, ClientFormModal } from "@/src/Components/Client";
|
import { AddressFormModal, DeleteConfirmModal, ClientFormModal } from "@/src/Components/Client";
|
||||||
import { CreateAddressFormValues, UpdateAddressFormValues } from "@/src/validations/client_address.validator";
|
import type {
|
||||||
|
CreateAddressFormValues,
|
||||||
|
UpdateAddressFormValues,
|
||||||
|
} from "@/src/validations/client_address.validator";
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Helpers
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Emoji icon for known address label types */
|
|
||||||
function labelIcon(label: string): string {
|
function labelIcon(label: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
"فوترة": "💳",
|
"فوترة": "💳",
|
||||||
@@ -47,29 +53,28 @@ function labelIcon(label: string): string {
|
|||||||
return map[label.toLowerCase()] ?? "📍";
|
return map[label.toLowerCase()] ?? "📍";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─── AddressCard ───────────────────────────────────────────────────────────
|
||||||
// AddressCard sub-component
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface AddressCardProps {
|
interface AddressCardProps {
|
||||||
address: ClientAddress;
|
address: ClientAddress;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onMakePrimary: () => void;
|
|
||||||
/** Whether a "set primary" action is pending for this specific card */
|
|
||||||
settingPrimary?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function AddressCard({ address, onEdit, onDelete }: AddressCardProps) {
|
||||||
* AddressCard — Renders a single address in the grid.
|
const { details, contactPerson } = address;
|
||||||
*/
|
const {
|
||||||
function AddressCard({
|
street,
|
||||||
address,
|
city,
|
||||||
onEdit,
|
state,
|
||||||
onDelete,
|
district,
|
||||||
onMakePrimary,
|
buildingNo,
|
||||||
settingPrimary = false,
|
unitNo,
|
||||||
}: AddressCardProps) {
|
additionalNo,
|
||||||
|
zipCode,
|
||||||
|
country,
|
||||||
|
} = details;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -78,26 +83,13 @@ function AddressCard({
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
borderRadius: "var(--radius-xl)",
|
borderRadius: "var(--radius-xl)",
|
||||||
border: address.isPrimary
|
border: "1px solid var(--color-border)",
|
||||||
? "1px solid #93C5FD"
|
|
||||||
: "1px solid var(--color-border)",
|
|
||||||
padding: "1.25rem",
|
padding: "1.25rem",
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
boxShadow: address.isPrimary
|
boxShadow: "var(--shadow-card)",
|
||||||
? "0 0 0 2px #BFDBFE, var(--shadow-card)"
|
|
||||||
: "var(--shadow-card)",
|
|
||||||
transition: "box-shadow 200ms",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Label row */}
|
{/* Label row */}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<span style={{ fontSize: 20 }} aria-hidden="true">
|
<span style={{ fontSize: 20 }} aria-hidden="true">
|
||||||
{labelIcon(address.label)}
|
{labelIcon(address.label)}
|
||||||
@@ -111,9 +103,21 @@ function AddressCard({
|
|||||||
>
|
>
|
||||||
{address.label}
|
{address.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{address.branchName && (
|
||||||
|
<span
|
||||||
{/* Primary badge */}
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
padding: "0.1rem 0.45rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address.branchName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{address.isPrimary && (
|
{address.isPrimary && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -123,16 +127,17 @@ function AddressCard({
|
|||||||
background: "#EFF6FF",
|
background: "#EFF6FF",
|
||||||
border: "1px solid #BFDBFE",
|
border: "1px solid #BFDBFE",
|
||||||
borderRadius: "var(--radius-full)",
|
borderRadius: "var(--radius-full)",
|
||||||
padding: "0.15rem 0.5rem",
|
padding: "0.1rem 0.45rem",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
marginInlineStart: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
★ رئيسي
|
أساسي
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address lines */}
|
{/* Address details — rendered fully in-card */}
|
||||||
<address
|
<address
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
style={{
|
||||||
@@ -140,15 +145,58 @@ function AddressCard({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: "var(--color-text-secondary)",
|
color: "var(--color-text-secondary)",
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ margin: 0 }}>{address.street}</p>
|
<p style={{ margin: 0, fontWeight: 600, color: "var(--color-text-primary)" }}>
|
||||||
<p style={{ margin: 0 }}>
|
{street}
|
||||||
{address.city}،{" "}{address.state} {address.postalCode}
|
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: 0 }}>{address.country}</p>
|
|
||||||
|
{district && <p style={{ margin: 0 }}>حي {district}</p>}
|
||||||
|
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
{city}
|
||||||
|
{state ? `، ${state}` : ""}
|
||||||
|
{zipCode ? ` ${zipCode}` : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(buildingNo || unitNo || additionalNo) && (
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: "var(--color-text-muted)" }} dir="ltr">
|
||||||
|
{buildingNo && `مبنى ${buildingNo}`}
|
||||||
|
{unitNo && ` · وحدة ${unitNo}`}
|
||||||
|
{additionalNo && ` · رقم إضافي ${additionalNo}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ margin: 0 }}>{country}</p>
|
||||||
</address>
|
</address>
|
||||||
|
|
||||||
|
{/* Contact person — only when present */}
|
||||||
|
{(contactPerson?.name || contactPerson?.phone) && (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600 }}>👤</span>
|
||||||
|
{contactPerson?.name && <span>{contactPerson.name}</span>}
|
||||||
|
{contactPerson?.phone && (
|
||||||
|
<span dir="ltr" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{contactPerson.phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -160,31 +208,9 @@ function AddressCard({
|
|||||||
borderTop: "1px solid var(--color-border)",
|
borderTop: "1px solid var(--color-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActionBtn
|
<ActionBtn onClick={onEdit} color="#1D4ED8" bg="#EFF6FF" border="#BFDBFE">
|
||||||
onClick={onEdit}
|
|
||||||
color="#1D4ED8"
|
|
||||||
bg="#EFF6FF"
|
|
||||||
border="#BFDBFE"
|
|
||||||
>
|
|
||||||
تعديل
|
تعديل
|
||||||
</ActionBtn>
|
</ActionBtn>
|
||||||
|
|
||||||
{/*
|
|
||||||
* Set-primary button — only for non-primary addresses.
|
|
||||||
* Shows a loading indicator while the API call is in-flight.
|
|
||||||
*/}
|
|
||||||
{!address.isPrimary && (
|
|
||||||
<ActionBtn
|
|
||||||
onClick={onMakePrimary}
|
|
||||||
color="#065F46"
|
|
||||||
bg="#DCFCE7"
|
|
||||||
border="#BBF7D0"
|
|
||||||
disabled={settingPrimary}
|
|
||||||
>
|
|
||||||
{settingPrimary ? "…" : "تعيين كرئيسي"}
|
|
||||||
</ActionBtn>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ActionBtn
|
<ActionBtn
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
color="#DC2626"
|
color="#DC2626"
|
||||||
@@ -242,157 +268,16 @@ function ActionBtn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─── ClientEditModal ───────────────────────────────────────────────────────
|
||||||
// AddressMiniList — used inside the client edit modal
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
interface ClientEditModalProps {
|
||||||
* AddressMiniList — A compact read-only list of addresses shown inside
|
|
||||||
* the ClientFormModal when editing an existing client.
|
|
||||||
*
|
|
||||||
* Purpose:
|
|
||||||
* Allows the user to see all addresses and designate one as primary
|
|
||||||
* without navigating away from the edit modal.
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* addresses — current list from useClientAddresses
|
|
||||||
* onMakePrimary — handler that calls clientAddressService.setPrimary
|
|
||||||
* settingId — id of the address currently being promoted (for loading state)
|
|
||||||
*/
|
|
||||||
function AddressMiniList({
|
|
||||||
addresses,
|
|
||||||
onMakePrimary,
|
|
||||||
settingId,
|
|
||||||
}: {
|
|
||||||
addresses: ClientAddress[];
|
|
||||||
onMakePrimary: (id: string) => void;
|
|
||||||
settingId: string | null;
|
|
||||||
}) {
|
|
||||||
if (addresses.length === 0) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px dashed var(--color-border)",
|
|
||||||
padding: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
لا توجد عناوين مضافة بعد.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
dir="rtl"
|
|
||||||
style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}
|
|
||||||
>
|
|
||||||
{addresses.map((addr) => (
|
|
||||||
<li
|
|
||||||
key={addr.id}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 8,
|
|
||||||
padding: "0.625rem 0.875rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: addr.isPrimary ? "1px solid #93C5FD" : "1px solid var(--color-border)",
|
|
||||||
background: addr.isPrimary ? "#EFF6FF" : "var(--color-surface-muted)",
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span aria-hidden="true">{labelIcon(addr.label)}</span>
|
|
||||||
<div>
|
|
||||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
|
|
||||||
{addr.label}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "var(--color-text-muted)", marginRight: 8 }}>
|
|
||||||
{addr.city}، {addr.state}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{addr.isPrimary ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#1D4ED8",
|
|
||||||
background: "#DBEAFE",
|
|
||||||
border: "1px solid #BFDBFE",
|
|
||||||
borderRadius: "var(--radius-full)",
|
|
||||||
padding: "0.1rem 0.45rem",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★ رئيسي
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onMakePrimary(addr.id)}
|
|
||||||
disabled={settingId === addr.id}
|
|
||||||
style={{
|
|
||||||
height: 24,
|
|
||||||
padding: "0 0.5rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid #BBF7D0",
|
|
||||||
background: "#DCFCE7",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#065F46",
|
|
||||||
cursor: settingId === addr.id ? "not-allowed" : "pointer",
|
|
||||||
opacity: settingId === addr.id ? 0.6 : 1,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{settingId === addr.id ? "…" : "تعيين كرئيسي"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// ClientEditModalWithAddresses — wraps ClientFormModal + AddressMiniList
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ClientEditModalWithAddresses
|
|
||||||
*/
|
|
||||||
interface ClientEditModalWithAddressesProps {
|
|
||||||
client: Client;
|
client: Client;
|
||||||
addresses: ClientAddress[];
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
||||||
onMakePrimary: (id: string) => Promise<boolean>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClientEditModalWithAddresses({
|
function ClientEditModal({ client, onClose, onSubmit }: ClientEditModalProps) {
|
||||||
client,
|
|
||||||
addresses,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
onMakePrimary,
|
|
||||||
}: ClientEditModalWithAddressesProps) {
|
|
||||||
const [settingId, setSettingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSetPrimary = async (id: string) => {
|
|
||||||
setSettingId(id);
|
|
||||||
await onMakePrimary(id);
|
|
||||||
setSettingId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// We reuse the backdrop and center the compound modal manually
|
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -409,23 +294,13 @@ function ClientEditModalWithAddresses({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: "2rem 1rem",
|
padding: "2rem 1rem",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
gap: "1rem",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left panel: client form */}
|
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 520,
|
maxWidth: 520,
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Render ClientFormModal in a "portal-less" way by embedding its JSX inline */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
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)",
|
||||||
@@ -433,105 +308,34 @@ function ClientEditModalWithAddresses({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* We mount ClientFormModal as a standalone dialog — it handles its own
|
|
||||||
form logic. We intercept onClose to close the whole compound modal. */}
|
|
||||||
<ClientFormModal
|
<ClientFormModal
|
||||||
editClient={client}
|
editClient={client}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
|
||||||
* Address section — appended below the form card.
|
|
||||||
* Satisfies: "create a section that displays all available addresses."
|
|
||||||
*/}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderRadius: "var(--radius-xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Section header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "0.875rem 1.25rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface-muted)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "0.25em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "#2563EB",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
عناوين العميل
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: "2px 0 0",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{addresses.length} عنوان — اضغط "تعيين كرئيسي" لتغيير العنوان الرئيسي
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address mini-list */}
|
|
||||||
<div style={{ padding: "1rem" }}>
|
|
||||||
<AddressMiniList
|
|
||||||
addresses={addresses}
|
|
||||||
onMakePrimary={handleSetPrimary}
|
|
||||||
settingId={settingId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─── Page ──────────────────────────────────────────────────────────────────
|
||||||
// Page component
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function ClientAddressesPage() {
|
export default function ClientAddressesPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// FIX: "id is undefined" 404 bug.
|
|
||||||
// params.clientId only works if your route folder is named [clientId].
|
|
||||||
// If your folder is actually [id], params.clientId is always undefined,
|
|
||||||
// which becomes the string "undefined" in API URLs like /client/undefined/addresses.
|
|
||||||
// CHECK YOUR FOLDER NAME and keep only the line that matches it:
|
|
||||||
const clientId = (params?.clientId ?? params?.id) as string | undefined;
|
const clientId = (params?.clientId ?? params?.id) as string | undefined;
|
||||||
|
|
||||||
// FIX: warn loudly in dev instead of silently calling the API with no id
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
console.warn("ClientAddressesPage: clientId is missing from route params. Check your folder name (must match the key used in useParams()).");
|
console.warn("ClientAddressesPage: clientId missing from route params.");
|
||||||
}
|
}
|
||||||
}, [clientId]);
|
}, [clientId]);
|
||||||
|
|
||||||
// ── Parent client meta ───────────────────────────────────────────────────
|
// ── Parent client ────────────────────────────────────────────────────────
|
||||||
const [client, setClient] = useState<Client | null>(null);
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
const [clientLoading, setClientLoading] = useState(true);
|
const [clientLoading, setClientLoading] = useState(true);
|
||||||
|
|
||||||
// Fetch the parent client so we can show their name / details in the header
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clientId) return;
|
if (!clientId) return;
|
||||||
setClientLoading(true);
|
setClientLoading(true);
|
||||||
@@ -542,56 +346,31 @@ export default function ClientAddressesPage() {
|
|||||||
.finally(() => setClientLoading(false));
|
.finally(() => setClientLoading(false));
|
||||||
}, [clientId, router]);
|
}, [clientId, router]);
|
||||||
|
|
||||||
// ── Address CRUD hook ────────────────────────────────────────────────────
|
// ── Address hook ─────────────────────────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
addresses,
|
addresses,
|
||||||
loading: addrLoading,
|
loading: addrLoading,
|
||||||
error,
|
error,
|
||||||
notification, // { type, message } — matches ToastNotification
|
notification,
|
||||||
clearError,
|
clearError,
|
||||||
createAddress,
|
createAddress,
|
||||||
updateAddress,
|
updateAddress,
|
||||||
deleteAddress,
|
deleteAddress,
|
||||||
makePrimary,
|
|
||||||
reload,
|
reload,
|
||||||
} = useClientAddresses(clientId ?? "");
|
} = useClientAddresses(clientId ?? "");
|
||||||
// NOTE: hook itself guards "if (!clientId) return" before calling the API,
|
|
||||||
// so passing "" here is safe — it just won't fetch until clientId is real.
|
|
||||||
|
|
||||||
// ── Modal state ──────────────────────────────────────────────────────────
|
// ── Modal state ──────────────────────────────────────────────────────────
|
||||||
// Address form: false = closed | null = create | ClientAddress = edit
|
|
||||||
const [addrFormTarget, setAddrFormTarget] = useState<ClientAddress | null | false>(false);
|
const [addrFormTarget, setAddrFormTarget] = useState<ClientAddress | null | false>(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// Client edit modal (with address section)
|
|
||||||
const [editingClient, setEditingClient] = useState(false);
|
const [editingClient, setEditingClient] = useState(false);
|
||||||
|
|
||||||
// Per-card primary-setting loading state (card id)
|
|
||||||
const [settingPrimaryId, setSettingPrimaryId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ── Address form handler ─────────────────────────────────────────────────
|
// ── Address form handler ─────────────────────────────────────────────────
|
||||||
// FIX: "addrFormTarget!.id" used "!" which only blocks null/undefined,
|
|
||||||
// NOT "false" — but addrFormTarget's type is "ClientAddress | null | false".
|
|
||||||
// If addrFormTarget was ever false here, ".id" would be undefined at runtime,
|
|
||||||
// causing the "id is undefined" 404 bug from the API call.
|
|
||||||
// We now check addrFormTarget directly instead of trusting the isNew param,
|
|
||||||
// so create vs update can never disagree with each other.
|
|
||||||
const handleAddressSubmit = async (
|
const handleAddressSubmit = async (
|
||||||
data: CreateAddressFormValues | UpdateAddressFormValues
|
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (addrFormTarget === null) {
|
if (addrFormTarget === null) return createAddress(data as CreateAddressFormValues);
|
||||||
// create mode
|
if (addrFormTarget === false) return false;
|
||||||
return createAddress(data as CreateAddressFormValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addrFormTarget === false) {
|
|
||||||
// modal isn't even open — this should never happen, but guard anyway
|
|
||||||
console.error("handleAddressSubmit called while addrFormTarget is false (modal closed)");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update mode — addrFormTarget is a real ClientAddress here, .id is safe
|
|
||||||
return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues);
|
return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -604,45 +383,25 @@ export default function ClientAddressesPage() {
|
|||||||
if (ok) setDeleteTarget(null);
|
if (ok) setDeleteTarget(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Set-primary handler ──────────────────────────────────────────────────
|
// ── Client edit handler ──────────────────────────────────────────────────
|
||||||
/**
|
|
||||||
* makePrimaryForCard — wraps makePrimary from the hook with per-card
|
|
||||||
* loading state so each card can show its own spinner independently.
|
|
||||||
*/
|
|
||||||
const makePrimaryForCard = async (id: string): Promise<boolean> => {
|
|
||||||
setSettingPrimaryId(id);
|
|
||||||
const ok = await makePrimary(id);
|
|
||||||
setSettingPrimaryId(null);
|
|
||||||
return ok;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Client edit submit ───────────────────────────────────────────────────
|
|
||||||
const handleClientEditSubmit = async (
|
const handleClientEditSubmit = async (
|
||||||
data: ClientFormData,
|
data: ClientFormData,
|
||||||
_isNew: boolean,
|
_isNew: boolean
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!client) return false;
|
if (!client) return false;
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const res = await clientService.update(client.id, data, getStoredToken());
|
||||||
const res = await clientService.update(client.id, data, token);
|
setClient(res.data);
|
||||||
setClient(res.data); // optimistically update local state
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Guard: wait for client ───────────────────────────────────────────────
|
// ── Loading guard ────────────────────────────────────────────────────────
|
||||||
if (!clientId || clientLoading) {
|
if (!clientId || clientLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh" }}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
minHeight: "100vh",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -660,14 +419,9 @@ export default function ClientAddressesPage() {
|
|||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/*
|
|
||||||
* Global toast — sourced from UI barrel (not the old Client/Toast).
|
|
||||||
* The notification object from useClientAddresses has the same shape
|
|
||||||
* as ToastNotification: { type: "success" | "error"; message: string }.
|
|
||||||
*/}
|
|
||||||
<Toast notification={notification} />
|
<Toast notification={notification} />
|
||||||
|
|
||||||
{/* ── Address create / edit modal ── */}
|
{/* Address create / edit modal */}
|
||||||
{addrFormTarget !== false && (
|
{addrFormTarget !== false && (
|
||||||
<AddressFormModal
|
<AddressFormModal
|
||||||
editAddress={addrFormTarget}
|
editAddress={addrFormTarget}
|
||||||
@@ -676,11 +430,7 @@ export default function ClientAddressesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/*
|
{/* Delete confirmation */}
|
||||||
* ── Address delete confirmation ──
|
|
||||||
* We cast the address to the Client shape that DeleteConfirmModal expects
|
|
||||||
* (it only uses id + name fields from Client).
|
|
||||||
*/}
|
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<DeleteConfirmModal
|
<DeleteConfirmModal
|
||||||
client={
|
client={
|
||||||
@@ -699,24 +449,18 @@ export default function ClientAddressesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/*
|
{/* Client edit modal */}
|
||||||
* ── Client edit modal with embedded address section ──
|
|
||||||
* TEST: Click "تعديل بيانات العميل" in the header.
|
|
||||||
* → Modal shows client form + address list with "تعيين كرئيسي" buttons.
|
|
||||||
*/}
|
|
||||||
{editingClient && client && (
|
{editingClient && client && (
|
||||||
<ClientEditModalWithAddresses
|
<ClientEditModal
|
||||||
client={client}
|
client={client}
|
||||||
addresses={addresses}
|
|
||||||
onClose={() => setEditingClient(false)}
|
onClose={() => setEditingClient(false)}
|
||||||
onSubmit={handleClientEditSubmit}
|
onSubmit={handleClientEditSubmit}
|
||||||
onMakePrimary={makePrimaryForCard}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* ── Page header ── */}
|
{/* ── Header ── */}
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
borderRadius: "var(--radius-xl)",
|
borderRadius: "var(--radius-xl)",
|
||||||
@@ -726,7 +470,6 @@ export default function ClientAddressesPage() {
|
|||||||
boxShadow: "var(--shadow-card)",
|
boxShadow: "var(--shadow-card)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Back link */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/dashboard/clients")}
|
onClick={() => router.push("/dashboard/clients")}
|
||||||
@@ -744,61 +487,29 @@ export default function ClientAddressesPage() {
|
|||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M15 18l-6-6 6-6" />
|
<path d="M15 18l-6-6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
جميع العملاء
|
جميع العملاء
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||||
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
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
style={{
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{client ? `${client.name} — العناوين` : "العناوين"}
|
{client ? `${client.name} — العناوين` : "العناوين"}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
style={{
|
|
||||||
marginTop: "0.25rem",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إجمالي{" "}
|
إجمالي{" "}
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
<strong style={{ color: "var(--color-text-primary)" }}>{addresses.length}</strong>{" "}
|
||||||
{addresses.length}
|
|
||||||
</strong>{" "}
|
|
||||||
عنوان
|
عنوان
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header action buttons */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
{/* Edit client (opens compound modal with address section) */}
|
|
||||||
{client && (
|
{client && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -819,14 +530,7 @@ export default function ClientAddressesPage() {
|
|||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -834,7 +538,6 @@ export default function ClientAddressesPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add address button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAddrFormTarget(null)}
|
onClick={() => setAddrFormTarget(null)}
|
||||||
@@ -856,14 +559,7 @@ export default function ClientAddressesPage() {
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
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="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -887,18 +583,8 @@ export default function ClientAddressesPage() {
|
|||||||
borderTop: "1px solid var(--color-border)",
|
borderTop: "1px solid var(--color-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{client.name}</span>
|
||||||
style={{
|
<a href={`mailto:${client.email}`} style={{ color: "#2563EB", textDecoration: "none" }}>
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{client.name}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={`mailto:${client.email}`}
|
|
||||||
style={{ color: "#2563EB", textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
{client.email}
|
{client.email}
|
||||||
</a>
|
</a>
|
||||||
<span>{client.phone}</span>
|
<span>{client.phone}</span>
|
||||||
@@ -919,29 +605,11 @@ export default function ClientAddressesPage() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/*
|
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||||
* Error alert — shown when the address load fails.
|
|
||||||
* Uses the UI/Alert component with an onClose dismiss handler.
|
|
||||||
* TEST: Simulate a network error → alert banner appears above the grid.
|
|
||||||
*/}
|
|
||||||
{error && (
|
|
||||||
<Alert type="error" message={error} onClose={clearError} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Address grid ── */}
|
{/* ── Address grid ── */}
|
||||||
{addrLoading ? (
|
{addrLoading ? (
|
||||||
/* Loading skeleton-ish state */
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "4rem 0", color: "var(--color-text-muted)", gap: 12, fontSize: 13 }}>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "4rem 0",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
gap: 12,
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -956,7 +624,6 @@ export default function ClientAddressesPage() {
|
|||||||
جارٍ تحميل العناوين…
|
جارٍ تحميل العناوين…
|
||||||
</div>
|
</div>
|
||||||
) : addresses.length === 0 ? (
|
) : addresses.length === 0 ? (
|
||||||
/* Empty state */
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderRadius: "var(--radius-xl)",
|
borderRadius: "var(--radius-xl)",
|
||||||
@@ -968,51 +635,21 @@ export default function ClientAddressesPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ fontSize: 32, margin: 0 }}>📍</p>
|
<p style={{ fontSize: 32, margin: 0 }}>📍</p>
|
||||||
<p
|
<p style={{ marginTop: 12, fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)" }}>
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
لا توجد عناوين بعد
|
لا توجد عناوين بعد
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p style={{ marginTop: 4, fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
style={{
|
|
||||||
marginTop: 4,
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات والفواتير.
|
أضف عنواناً واحداً على الأقل حتى يتمكن العميل من استقبال الشحنات والفواتير.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAddrFormTarget(null)}
|
onClick={() => setAddrFormTarget(null)}
|
||||||
style={{
|
style={{ marginTop: 16, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}
|
||||||
marginTop: 16,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-brand-600)",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
textDecoration: "underline",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
أضف أول عنوان
|
أضف أول عنوان
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/*
|
|
||||||
* Address cards grid.
|
|
||||||
* TEST:
|
|
||||||
* - Click "تعديل" → AddressFormModal opens pre-filled.
|
|
||||||
* - Click "تعيين كرئيسي" → button shows "…" loading, then card re-renders
|
|
||||||
* with "★ رئيسي" badge and blue ring; previously primary card loses the badge.
|
|
||||||
* - Click "حذف" → DeleteConfirmModal opens → confirm → card removed, toast shown.
|
|
||||||
*/
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -1026,8 +663,6 @@ export default function ClientAddressesPage() {
|
|||||||
address={addr}
|
address={addr}
|
||||||
onEdit={() => setAddrFormTarget(addr)}
|
onEdit={() => setAddrFormTarget(addr)}
|
||||||
onDelete={() => setDeleteTarget(addr)}
|
onDelete={() => setDeleteTarget(addr)}
|
||||||
onMakePrimary={() => makePrimaryForCard(addr.id)}
|
|
||||||
settingPrimary={settingPrimaryId === addr.id}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -1,238 +1,7 @@
|
|||||||
"use client";
|
import React from 'react'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
export default function page() {
|
||||||
import { Alert, Spinner } from "@/src/Components/UI";
|
return (
|
||||||
import { RoleToast } from "@/src/Components/role/RoleToast";
|
<div>page</div>
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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)}
|
||||||
|
|||||||
249
src/Components/Branch/BranchDetailModal.tsx
Normal file
249
src/Components/Branch/BranchDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
src/Components/Branch/BranchFormModal.tsx
Normal file
261
src/Components/Branch/BranchFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/Components/Branch/BranchTable.tsx
Normal file
155
src/Components/Branch/BranchTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/Components/Branch/DeleteConfirmModal.tsx
Normal file
58
src/Components/Branch/DeleteConfirmModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/Components/Branch/index.ts
Normal file
4
src/Components/Branch/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { BranchTable } from "./BranchTable";
|
||||||
|
export { BranchFormModal } from "./BranchFormModal";
|
||||||
|
export { BranchDetailModal } from "./BranchDetailModal";
|
||||||
|
export { DeleteConfirmModal } from "./DeleteConfirmModal";
|
||||||
@@ -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 ?? "",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
258
src/Components/Client_Adress/AddressDetails.tsx
Normal file
258
src/Components/Client_Adress/AddressDetails.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,21 +217,12 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
|
|||||||
{/* Address Meta */}
|
{/* Address Meta */}
|
||||||
<p style={S.sectionTitle}>بيانات العنوان</p>
|
<p style={S.sectionTitle}>بيانات العنوان</p>
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr auto",
|
|
||||||
gap: "0.75rem",
|
|
||||||
alignItems: "end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* FIX 3: Replaced <select> with a free-text <input> */}
|
|
||||||
<label style={S.label}>
|
<label style={S.label}>
|
||||||
نوع العنوان
|
نوع العنوان
|
||||||
<input
|
<input
|
||||||
{...register("label")}
|
{...register("label")}
|
||||||
style={withError(!!errors.label && errors.label.type !== "manual")}
|
style={withError(!!errors.label && errors.label.type !== "manual")}
|
||||||
placeholder="مقترح: منزل / مكتب"
|
placeholder="مثال: منزل / مكتب / شحن"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
{errors.label && errors.label.type !== "manual" && (
|
{errors.label && errors.label.type !== "manual" && (
|
||||||
@@ -273,36 +230,9 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
|
|||||||
)}
|
)}
|
||||||
</label>
|
</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",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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={{
|
|
||||||
width: "100%", maxWidth: 420,
|
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
borderRadius: "var(--radius-xl)",
|
borderRadius: "var(--radius-xl)",
|
||||||
border: "1px solid #FECACA",
|
border: "1px solid #FECACA",
|
||||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
padding: "2rem",
|
padding: "2rem",
|
||||||
display: "flex", flexDirection: "column", gap: "1rem",
|
display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center",
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div style={{
|
|
||||||
width: 52, height: 52, margin: "0 auto",
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
}}>
|
}}>
|
||||||
|
{/* Icon */}
|
||||||
|
<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">
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,373 +1,198 @@
|
|||||||
"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>
|
||||||
const grouped = groupByModule(allPermissions);
|
</div>
|
||||||
const activeSet = new Set(currentIds);
|
);
|
||||||
|
|
||||||
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 handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setApiError("");
|
|
||||||
const ok = await onSave(role.id, [...selected]);
|
|
||||||
setSaving(false);
|
|
||||||
if (ok) {
|
|
||||||
setSaved(true);
|
|
||||||
setTimeout(() => setSaved(false), 2000);
|
|
||||||
} else {
|
|
||||||
setApiError("حدث خطأ أثناء حفظ الصلاحيات. حاول مرة أخرى.");
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleDetailModal({ roleId, onClose }: RoleDetailModalProps) {
|
||||||
|
const [role, setRole] = useState<Role | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await roleService.getById(roleId, token);
|
||||||
|
if (!cancelled) setRole((res as unknown as { data: Role }).data);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("تعذّر تحميل بيانات الدور. يرجى المحاولة لاحقاً.");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [roleId]);
|
||||||
|
|
||||||
|
const fmt = (iso?: string | null) =>
|
||||||
|
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
|
||||||
|
|
||||||
return (
|
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"
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 640,
|
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div style={{
|
||||||
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",
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface-muted)",
|
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",
|
|
||||||
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)" }}>
|
|
||||||
{selected.size} من {allPermissions.length} صلاحية مفعّلة لهذا الدور
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.entries(grouped).map(([mod, perms], idx) => (
|
|
||||||
<div
|
|
||||||
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
|
)}
|
||||||
style={{
|
|
||||||
display: "grid",
|
{!loading && error && (
|
||||||
gridTemplateColumns:
|
<div style={{ padding: "1rem 1.25rem", borderRadius: "var(--radius-lg)", background: "#FEF2F2", border: "1px solid #FECACA", fontSize: 13, color: "#991B1B", fontWeight: 500, textAlign: "center" }}>
|
||||||
"repeat(auto-fill, minmax(180px, 1fr))",
|
{error}
|
||||||
gap: "0.5rem",
|
</div>
|
||||||
padding: "0.75rem 1rem",
|
)}
|
||||||
}}
|
|
||||||
>
|
{!loading && role && (
|
||||||
{perms.map((perm) => {
|
<div dir="rtl">
|
||||||
const checked = selected.has(perm.id);
|
{/* Role icon + name + status */}
|
||||||
const wasActive = activeSet.has(perm.id);
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--color-border)", marginBottom: "0.25rem" }}>
|
||||||
return (
|
<div style={{
|
||||||
<label
|
width: 56, height: 56, borderRadius: "var(--radius-xl)",
|
||||||
key={perm.id}
|
background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
|
||||||
style={{
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
display: "flex",
|
fontSize: 22, flexShrink: 0,
|
||||||
alignItems: "flex-start",
|
}}>
|
||||||
gap: 8,
|
🛡️
|
||||||
padding: "0.5rem 0.625rem",
|
</div>
|
||||||
borderRadius: "var(--radius-md)",
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
|
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
|
||||||
background: checked
|
{role.description && (
|
||||||
? "#EFF6FF"
|
<p style={{ marginTop: 3, fontSize: 12, color: "var(--color-text-muted)" }}>{role.description}</p>
|
||||||
: "var(--color-surface-muted)",
|
)}
|
||||||
cursor: "pointer",
|
<div style={{ marginTop: 8 }}>
|
||||||
}}
|
<StatusBadge active={role.isActive} />
|
||||||
>
|
</div>
|
||||||
<input
|
</div>
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggle(perm.id)}
|
<DetailRow label="تاريخ الإنشاء" value={fmt(role.createdAt)} />
|
||||||
style={{
|
{role.updatedAt && <DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />}
|
||||||
width: 14,
|
|
||||||
height: 14,
|
{/* Permissions list */}
|
||||||
marginTop: 1,
|
{role.permissions && role.permissions.length > 0 && (
|
||||||
cursor: "pointer",
|
<div style={{ paddingTop: "0.75rem" }}>
|
||||||
accentColor: "#2563EB",
|
<p style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)", margin: "0 0 10px" }}>
|
||||||
}}
|
الصلاحيات ({role.permissions.length})
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: checked
|
|
||||||
? "#1D4ED8"
|
|
||||||
: "var(--color-text-primary)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{perm.name}
|
|
||||||
</p>
|
</p>
|
||||||
<span
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
|
||||||
style={{
|
{role.permissions.map(({ permission }) => (
|
||||||
fontSize: 9,
|
<span key={permission.id} style={{
|
||||||
fontWeight: 700,
|
display: "inline-flex", alignItems: "center",
|
||||||
padding: "1px 6px",
|
padding: "0.25rem 0.6rem", borderRadius: "var(--radius-md)",
|
||||||
borderRadius: "var(--radius-full)",
|
background: "#EFF6FF", border: "1px solid #BFDBFE",
|
||||||
color: wasActive ? "#166534" : "#991B1B",
|
fontSize: 11, fontWeight: 600, color: "#1D4ED8",
|
||||||
background: wasActive ? "#DCFCE7" : "#FEF2F2",
|
}}>
|
||||||
}}
|
{permission.name}
|
||||||
>
|
|
||||||
{wasActive ? "نشطة حالياً" : "غير نشطة"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
|||||||
@@ -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: "العملاء",
|
|
||||||
Permission: "الصلاحيات",
|
|
||||||
Audit: "سجل التدقيق",
|
|
||||||
Dashboard: "لوحة التحكم",
|
|
||||||
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;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
interface FormErrors {
|
}
|
||||||
name?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate(name: string): FormErrors {
|
// ── Permission checkbox group ─────────────────────────────────────────────────
|
||||||
const e: FormErrors = {};
|
/*function PermissionGroup({
|
||||||
if (!name.trim()) e.name = "اسم الدور مطلوب";
|
module, perms, selected, onToggle,
|
||||||
else if (name.trim().length < 2) e.name = "الاسم يجب أن يكون حرفين على الأقل";
|
}: {
|
||||||
return e;
|
module: string; perms: Permission[]; selected: string[]; onToggle: (id: string) => void;
|
||||||
}
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<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 [form, setForm] = useState<RoleFormData>({
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
name: editRole?.name ?? "",
|
||||||
|
description: editRole?.description ?? "",
|
||||||
|
permissionIds: currentPermIds,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<RoleFormErrors>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
const [searchPerm, setSearchPerm] = useState("");
|
|
||||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
|
||||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const grouped = groupByModule(permissions);
|
useEffect(() => { firstInputRef.current?.focus(); }, []);
|
||||||
|
|
||||||
// Auto-expand modules that have selected permissions or match search
|
|
||||||
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 dir="rtl">
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||||
<div>
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
|
||||||
<p style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-secondary)", margin: 0 }}>
|
|
||||||
الصلاحيات
|
الصلاحيات
|
||||||
</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" }}>
|
||||||
</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>
|
||||||
<button type="button" onClick={clearAll}
|
<button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: [] }))}
|
||||||
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)" }}>
|
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
||||||
إلغاء الكل
|
إلغاء الكل
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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)" }}>
|
|
||||||
لا توجد نتائج لـ "{searchPerm}"
|
|
||||||
</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>
|
</div>
|
||||||
</button>
|
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 6 }}>
|
||||||
</div>
|
{form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
|
||||||
|
|
||||||
{/* 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
)}*/}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={role.id}
|
|
||||||
onClick={() => onView(role)}
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "2fr 2.5fr 1fr 1fr 80px",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
padding: "0.875rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Role name + ID */}
|
|
||||||
<div>
|
|
||||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
|
|
||||||
{role.name}
|
|
||||||
</p>
|
|
||||||
{role.id && (
|
|
||||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>
|
|
||||||
{role.id.slice(0, 8)}…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permissions preview */}
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.25rem", alignItems: "center" }}>
|
|
||||||
{permCount === 0 ? (
|
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-hint)" }}>لا توجد صلاحيات</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{modules.map(mod => (
|
|
||||||
<span key={mod} style={{
|
|
||||||
fontSize: 10, fontWeight: 600,
|
|
||||||
color: "#374151",
|
|
||||||
background: "#F3F4F6",
|
|
||||||
border: "1px solid #E5E7EB",
|
|
||||||
borderRadius: "var(--radius-full)",
|
|
||||||
padding: "2px 7px",
|
|
||||||
}}>
|
}}>
|
||||||
{mod}
|
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>{role.name}</span>
|
||||||
|
<span style={{ color: "var(--color-text-secondary)", fontSize: 12 }}>
|
||||||
|
{role.description || <span style={{ color: "var(--color-text-hint)", fontStyle: "italic" }}>لا يوجد وصف</span>}
|
||||||
</span>
|
</span>
|
||||||
))}
|
|
||||||
{extra > 0 && (
|
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 10, fontWeight: 700,
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||||
color: "#2563EB",
|
width: 28, height: 28, borderRadius: "var(--radius-full)",
|
||||||
background: "var(--color-brand-50)",
|
background: "#EFF6FF", border: "1px solid #BFDBFE",
|
||||||
border: "1px solid var(--color-brand-200)",
|
fontSize: 11, fontWeight: 700, color: "#1D4ED8",
|
||||||
borderRadius: "var(--radius-full)",
|
|
||||||
padding: "2px 7px",
|
|
||||||
}}>
|
}}>
|
||||||
+{extra}
|
{role.permissions?.length ?? 0}
|
||||||
</span>
|
</span>
|
||||||
)}
|
<StatusBadge active={role.isActive} />
|
||||||
<span style={{ fontSize: 10, color: "var(--color-text-muted)", marginRight: 2 }}>
|
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||||
({permCount} صلاحية)
|
{/* View */}
|
||||||
</span>
|
<IconBtn title={`عرض ${role.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(role)}>
|
||||||
</>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
)}
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
</div>
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
{/* Status badge */}
|
</IconBtn>
|
||||||
<StatusBadge active={role.isActive !== false} />
|
{/* Edit */}
|
||||||
|
<IconBtn title={`تعديل ${role.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(role)}>
|
||||||
{/* 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">
|
<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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<IconBtn
|
{/* Delete */}
|
||||||
title={`حذف ${role.name}`}
|
<IconBtn title={`حذف ${role.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(role)}>
|
||||||
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">
|
<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"/>
|
<polyline points="3 6 5 6 21 6" />
|
||||||
<path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
<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>
|
</svg>
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
145
src/hooks/useBranch.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
56
src/services/branch.service.ts
Normal file
56
src/services/branch.service.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,84 +1,81 @@
|
|||||||
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,
|
||||||
|
|||||||
@@ -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),
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ export interface LoginResponse {
|
|||||||
token: string;
|
token: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}
|
}
|
||||||
|
export interface LoginPayload {
|
||||||
|
identity: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
83
src/types/client_adresses.ts
Normal file
83
src/types/client_adresses.ts
Normal 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" };
|
||||||
@@ -125,3 +125,4 @@ export const DRIVER_CARD_TYPE_MAP: Record<DriverCardType, string> = {
|
|||||||
Annual: "سنوية",
|
Annual: "سنوية",
|
||||||
Restricted: "مقيدة",
|
Restricted: "مقيدة",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
4
src/types/notif.ts
Normal file
4
src/types/notif.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Notification {
|
||||||
|
type: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -154,3 +154,4 @@ export const TRIP_STATUS_MAP: Record<
|
|||||||
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" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
152
src/validations/branch.validator.ts
Normal file
152
src/validations/branch.validator.ts
Normal 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
|
||||||
|
>
|
||||||
|
>;
|
||||||
28
src/validations/role.validator.ts
Normal file
28
src/validations/role.validator.ts
Normal 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>>;
|
||||||
Reference in New Issue
Block a user