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

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

View File

@@ -0,0 +1,249 @@
"use client";
import { useEffect, useState } from "react";
import { Spinner } from "../UI";
import { getStoredToken } from "@/src/lib/auth";
import { branchService } from "@/src/services/branch.service";
import type { BranchDetail } from "@/src/types/branch";
interface BranchDetailModalProps {
branchId: string;
onClose: () => void;
}
// ── small helper components ───────────────────────────────────────────────────
function DetailRow({ label, value }: { label: string; value?: string | null }) {
return (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function BranchIcon() {
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(37,99,235,.3)",
}}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#FFF" strokeWidth="2">
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
<path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01" />
</svg>
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps) {
const [branch, setBranch] = useState<BranchDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// fetch branch details on mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const token = getStoredToken();
const res = await branchService.getById(branchId, token);
if (!cancelled) setBranch(res.data);
} catch {
if (!cancelled) setError("تعذّر تحميل بيانات الفرع. يرجى المحاولة لاحقاً.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [branchId]);
// ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) =>
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
const fullAddress = (b: BranchDetail) =>
[b.street, b.district, b.city, b.state, b.country].filter(Boolean).join("، ") || null;
// ── render ────────────────────────────────────────────────────────────────
return (
<div
role="dialog" aria-modal="true" aria-labelledby="branch-detail-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 55,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
}}
>
{/* ── header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
بيانات الفرع
</p>
<h2 id="branch-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{branch?.name ?? "عرض الفرع"}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && (
<div style={{
padding: "1rem 1.25rem",
borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, color: "#991B1B", fontWeight: 500,
textAlign: "center",
}}>
{error}
</div>
)}
{/* content */}
{!loading && branch && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* icon + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<BranchIcon />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{branch.name}</p>
{branch.city && (
<p style={{ marginTop: 3, fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{branch.city}
</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={branch.isActive} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={branch.phone} />
<DetailRow label="البريد الإلكتروني" value={branch.email} />
<DetailRow label="العنوان" value={fullAddress(branch)} />
<DetailRow label="رقم المبنى" value={branch.buildingNo} />
<DetailRow label="رقم الوحدة" value={branch.unitNo} />
<DetailRow label="الرمز البريدي" value={branch.zipCode} />
<DetailRow
label="الموقع الجغرافي"
value={branch.latitude != null && branch.longitude != null
? `${branch.latitude}, ${branch.longitude}`
: null}
/>
<DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
</div>
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as yup from "yup";
import { Alert, Spinner } from "../UI";
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
import type { Branch, BranchFormData, FormErrors } from "@/src/types/branch";
// ── fixed styles ─────────────────────────────────────────
const S = {
input: {
width: "100%", height: 40, padding: "0 0.75rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)",
outline: "none", fontFamily: "var(--font-sans)",
} as React.CSSProperties,
label: {
display: "flex", flexDirection: "column" as const,
gap: 6, fontSize: 12, fontWeight: 600,
color: "var(--color-text-secondary)",
} as React.CSSProperties,
errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties,
};
// ── yup validation ────────────────────────────────────────────────────────────
async function validate(data: BranchFormData, isNew: boolean): Promise<FormErrors> {
const schema = isNew ? createBranchSchema : updateBranchSchema;
try {
await schema.validate(data, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<FormErrors>((acc, e) => {
const field = e.path as keyof FormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface BranchFormModalProps {
editBranch: Branch | null;
onClose: () => void;
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
}
// ── main component ────────────────────────────────────────────────────────────
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
const isNew = editBranch === null;
const [form, setForm] = useState<BranchFormData>({
name: editBranch?.name ?? "",
email: editBranch?.email ?? "",
phone: editBranch?.phone ?? "",
country: editBranch?.country ?? "SA",
city: editBranch?.city ?? "",
state: editBranch?.state ?? "",
district: editBranch?.district ?? "",
street: editBranch?.street ?? "",
buildingNo: editBranch?.buildingNo ?? "",
unitNo: editBranch?.unitNo ?? "",
zipCode: editBranch?.zipCode ?? "",
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "",
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "",
});
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { firstInputRef.current?.focus(); }, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const set = (field: keyof BranchFormData) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
setForm(p => ({ ...p, [field]: e.target.value }));
// clear field error on change
if (errors[field]) setErrors(p => ({ ...p, [field]: undefined }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errs = await validate(form, isNew);
if (Object.keys(errs).length) { setErrors(errs); return; }
setSaving(true);
setApiError("");
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
};
// dynamic styles for inputs with errors
const inputStyle = (field: keyof FormErrors): React.CSSProperties => ({
...S.input,
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
});
return (
<div
role="dialog" aria-modal="true" aria-labelledby="branch-modal-title"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: "100%", maxWidth: 560,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
display: "flex", flexDirection: "column",
maxHeight: "90vh",
}}
>
{/* header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
{isNew ? "إضافة فرع" : "تعديل فرع"}
</p>
<h2 id="branch-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "فرع جديد" : editBranch?.name}
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto" }}>
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
{/* name */}
<label style={S.label}>
اسم الفرع *
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="فرع الرياض" autoComplete="organization" dir="rtl" />
{errors.name && <span style={S.errorText}>{errors.name}</span>}
</label>
{/* email + phone */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
البريد الإلكتروني
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="branch@co.sa" autoComplete="email" dir="ltr" />
{errors.email && <span style={S.errorText}>{errors.email}</span>}
</label>
<label style={S.label}>
رقم الهاتف
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" />
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
</label>
</div>
{/* city + street */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
المدينة *
<input style={inputStyle("city")} value={form.city} onChange={set("city")} placeholder="الرياض" dir="rtl" />
{errors.city && <span style={S.errorText}>{errors.city}</span>}
</label>
<label style={S.label}>
الشارع *
<input style={inputStyle("street")} value={form.street} onChange={set("street")} placeholder="شارع الملك فهد" dir="rtl" />
{errors.street && <span style={S.errorText}>{errors.street}</span>}
</label>
</div>
{/* state + district */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
المنطقة
<input style={inputStyle("state")} value={form.state} onChange={set("state")} placeholder="منطقة الرياض" dir="rtl" />
{errors.state && <span style={S.errorText}>{errors.state}</span>}
</label>
<label style={S.label}>
الحي
<input style={inputStyle("district")} value={form.district} onChange={set("district")} placeholder="حي العليا" dir="rtl" />
{errors.district && <span style={S.errorText}>{errors.district}</span>}
</label>
</div>
{/* buildingNo + unitNo + zipCode */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
رقم المبنى
<input style={inputStyle("buildingNo")} value={form.buildingNo} onChange={set("buildingNo")} placeholder="1234" dir="ltr" />
{errors.buildingNo && <span style={S.errorText}>{errors.buildingNo}</span>}
</label>
<label style={S.label}>
رقم الوحدة
<input style={inputStyle("unitNo")} value={form.unitNo} onChange={set("unitNo")} placeholder="5" dir="ltr" />
{errors.unitNo && <span style={S.errorText}>{errors.unitNo}</span>}
</label>
<label style={S.label}>
الرمز البريدي
<input style={inputStyle("zipCode")} value={form.zipCode} onChange={set("zipCode")} placeholder="12345" dir="ltr" />
{errors.zipCode && <span style={S.errorText}>{errors.zipCode}</span>}
</label>
</div>
{/* country */}
<label style={S.label}>
الدولة
<input style={inputStyle("country")} value={form.country} onChange={set("country")} placeholder="SA" dir="ltr" />
{errors.country && <span style={S.errorText}>{errors.country}</span>}
</label>
{/* latitude + longitude */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<label style={S.label}>
خط العرض (اختياري)
<input style={inputStyle("latitude")} value={form.latitude} onChange={set("latitude")} placeholder="24.7136" dir="ltr" inputMode="decimal" />
{errors.latitude && <span style={S.errorText}>{errors.latitude}</span>}
</label>
<label style={S.label}>
خط الطول (اختياري)
<input style={inputStyle("longitude")} value={form.longitude} onChange={set("longitude")} placeholder="46.6753" dir="ltr" inputMode="decimal" />
{errors.longitude && <span style={S.errorText}>{errors.longitude}</span>}
</label>
</div>
{/* actions */}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button type="button" onClick={onClose} disabled={saving}
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="submit" disabled={saving}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة الفرع" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
"use client";
import { Spinner } from "../UI";
import type { Branch } from "@/src/types/branch";
// ── status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── card / header styles ─────────────────────────────────────────────────────
const cardStyle: React.CSSProperties = {
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)",
};
const thStyle: React.CSSProperties = {
padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.2em",
color: "var(--color-text-muted)", background: "var(--color-surface-muted)",
borderBottom: "1px solid var(--color-border)",
};
// ── Props ────────────────────────────────────────────────────────────────────
interface BranchTableProps {
branches: Branch[];
loading: boolean;
search: string;
page: number;
pages: number;
onEdit: (branch: Branch) => void;
onDelete: (branch: Branch) => void;
onView: (branch: Branch) => void;
onAddFirst: () => void;
onPageChange: (p: number) => void;
}
// ── main table ───────────────────────────────────────────────────────────────
export function BranchTable({ branches, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: BranchTableProps) {
return (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px", ...thStyle }}>
<span>الفرع</span>
<span>المدينة</span>
<span>الهاتف</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : branches.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
</p>
{!search && (
<button type="button" onClick={onAddFirst}
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
أضف أول فرع
</button>
)}
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{branches.map((b, i) => (
<li key={b.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 100px",
alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
fontSize: 13,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{b.name}</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>{b.phone ?? "—"}</span>
<StatusBadge active={b.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view */}
<IconBtn title={`عرض ${b.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(b)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
{/* edit */}
<IconBtn title={`تعديل ${b.name}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(b)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</IconBtn>
{/* delete */}
<IconBtn title={`حذف ${b.name}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(b)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Branch } from "@/src/types/branch";
interface DeleteConfirmModalProps {
branch: Branch;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteConfirmModal({ branch, deleting, onCancel, onConfirm }: DeleteConfirmModalProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onCancel]);
return (
<div
role="alertdialog" aria-modal="true" aria-labelledby="branch-del-title"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}
>
<div
onClick={e => e.stopPropagation()}
style={{ width: "100%", maxWidth: 400, background: "var(--color-surface)", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem", display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center" }}
>
{/* icon delete */}
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</div>
<div>
<h2 id="branch-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف الفرع</h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف فرع <strong style={{ color: "var(--color-text-primary)" }}>{branch.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={onCancel} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
{deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,15 +1,5 @@
"use client";
/**
* ClientTable — Enhanced version
*
* Changes from original:
* 1. Clicking a row now opens an inline ClientDetailPanel (slide-down) showing full client info.
* 2. "Addresses" link inside the detail panel navigates to /dashboard/clients/[clientId]/addresses.
* 3. The row-level onClick still calls onManageAddresses (kept for backwards-compatibility),
* but the primary UX is now the expandable detail panel.
* 4. Active row is highlighted with a brand-tinted left border.
*/
import { useState } from "react";
import { Spinner } from "../UI";

View File

@@ -1,18 +1,7 @@
/**
* src/Components/Client/index.ts
*
* Barrel exports for all Client-domain components.
*
* NOTE: Toast is re-exported here for backwards-compatibility, but it now
* delegates to src/Components/UI/Toast. Prefer importing Toast directly
* from "@/src/Components/UI" in new code.
*/
export { ClientFormModal } from "./Clientformmodal";
export { ClientTable } from "./Clienttable";
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
export { DeleteConfirmModal } from "./Deleteconfirmmodal";
// Toast: re-exported from Client/Toast, which itself re-exports UI/Toast.
// Maintains backwards-compat for any consumer importing from "@/src/Components/Client".
export { Toast } from "./Toast";

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ export function DeleteConfirmModal({ user, deleting, onCancel, onConfirm }: Dele
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" />

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,8 @@
"use client";
// Components/Role/DeleteRoleModal.tsx
// Confirm deletion of a role — mirrors DeleteConfirmModal from Users.
import { useEffect } from "react";
import { Spinner } from "../UI";
import type { Role } from "../../../services/role.service";
import { Role } from "@/src/types/role";
interface DeleteRoleModalProps {
role: Role;
@@ -15,18 +13,14 @@ interface DeleteRoleModalProps {
export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteRoleModalProps) {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onCancel]);
const permCount = role.permissions?.length ?? 0;
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="del-role-title"
role="alertdialog" aria-modal="true" aria-labelledby="del-role-title"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{
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",
}}
>
<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 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 */}
<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">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
<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>
@@ -66,60 +52,18 @@ export function DeleteRoleModal({ role, deleting, onCancel, onConfirm }: DeleteR
حذف الدور
</h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف دور{" "}
<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" }}>
هل أنت متأكد من حذف دور <strong style={{ color: "var(--color-text-primary)" }}>{role.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 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,
}}
>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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