refactor(forms): migrate form modals to react-hook-form + yupResolver, and adopt shared UI kit components across Role/Order/User/Trip modals and tables
Replace manual useState + validate() wiring with react-hook-form and
yupResolver across CarFormModal, DriverFormModal, Tripformmodal,
OrderFormModal, BranchFormModal, CarMaintananceFormModal, RoleFormModal,
and UserFormModal, following the existing pattern in Addressformmodal.
- Pin yupResolver's generic explicitly (yupResolver<FormValues>(schema as any))
since create/update schemas differ structurally and yup can't unify them
into a single inferred type on its own.
- Replace manual ref-based autofocus (useRef + ref={firstRef}) with
autoFocus/setFocus to avoid ref collisions with register()'s own ref.
- DriverFormModal: bind photo/nationalPhoto/driverCardPhoto file fields via
Controller instead of register.
- Tripformmodal: convert startTime/endTime to ISO-8601 via setValueAs while
keeping the datetime-local input display unaffected.
- OrderFormModal: register nested deliveryAddress/pickupAddress fields via
dot-path syntax; keep pickupMode as local state; preserve conditional
address payload assembly.
- CarMaintananceFormModal: keep toNumberOrUndefined cost coercion and
ISO-8601 date conversion at submit time.
- RoleFormModal: bind permissionIds checkbox group via Controller.
- UserFormModal: add a custom resolver wrapper to preserve the
empty-password-on-edit behavior (skip password validation when editing
with a blank password field).
Also replace raw HTML elements with the shared UI kit components
(Modal, Input, Textarea, Select, Button, Alert, Badge, EmptyState) across
form modals, detail modals, and tables:
- RoleFormModal: Modal, Input, Textarea, Button
- OrderFormModal: Modal, Input, Select, Button
- UserDetailModal / Archiveduserdetailmodal: Modal, Alert, Button
- UserTable / Archivedusertable: Badge, Button, EmptyState
- RoleDetailModal / ArchivedRoleDetailModal: Modal, Alert, Select, Button
- RoleTable / ArchivedRoleTable: Button, EmptyState
- Tripreportpanel: Button with built-in loading state instead of manual
Spinner/disabled/color wiring
StatusBadge, IconBtn, and other per-action custom chips/checkboxes are kept
as-is in every file — the shared Badge/Button components don't support dot
indicators or per-action color-coding, so forcing them in would misshape or
strip semantics from these controls.
Archivedusersmodal, ArchivedRolesModal, and ArchivedTripList required no
changes — already fully compliant with the shared UI kit.
No changes to visual layout, CSS, or Arabic labels/section headings beyond
what's required by the component swaps above.
This commit is contained in:
@@ -1,31 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useForm } from "react-hook-form";
|
||||||
import * as yup from "yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Modal } from "../UI";
|
import { Alert, Button, Input, Modal } from "../UI";
|
||||||
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
|
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
|
||||||
import type { Branch, BranchFormData, FormErrors } from "@/src/types/branch";
|
import type { Branch, BranchFormData } from "@/src/types/branch";
|
||||||
|
|
||||||
const FORM_ID = "branch-form";
|
const FORM_ID = "branch-form";
|
||||||
|
|
||||||
// ── 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 ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
interface BranchFormModalProps {
|
interface BranchFormModalProps {
|
||||||
editBranch: Branch | null;
|
editBranch: Branch | null;
|
||||||
@@ -37,7 +19,18 @@ interface BranchFormModalProps {
|
|||||||
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
|
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
|
||||||
const isNew = editBranch === null;
|
const isNew = editBranch === null;
|
||||||
|
|
||||||
const [form, setForm] = useState<BranchFormData>({
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<BranchFormData>({
|
||||||
|
// Cast the schema itself and pin the generic explicitly on yupResolver —
|
||||||
|
// create/update schemas differ structurally in which fields are required
|
||||||
|
// (e.g. city/street are optional on update), so yup can't unify them
|
||||||
|
// into the single flat BranchFormData shape on its own.
|
||||||
|
resolver: yupResolver<BranchFormData>((isNew ? createBranchSchema : updateBranchSchema) as any),
|
||||||
|
defaultValues: {
|
||||||
name: editBranch?.name ?? "",
|
name: editBranch?.name ?? "",
|
||||||
email: editBranch?.email ?? "",
|
email: editBranch?.email ?? "",
|
||||||
phone: editBranch?.phone ?? "",
|
phone: editBranch?.phone ?? "",
|
||||||
@@ -51,31 +44,16 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
zipCode: editBranch?.zipCode ?? "",
|
zipCode: editBranch?.zipCode ?? "",
|
||||||
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "",
|
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "",
|
||||||
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "",
|
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(); }, []);
|
const submitHandler = async (data: BranchFormData) => {
|
||||||
|
const ok = await onSubmit(data, isNew);
|
||||||
const set = (field: keyof BranchFormData) =>
|
if (ok) {
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
onClose();
|
||||||
setForm(p => ({ ...p, [field]: e.target.value }));
|
} else {
|
||||||
// clear field error on change
|
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||||
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("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,10 +65,10 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
size="md"
|
size="md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
إلغاء
|
إلغاء
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form={FORM_ID} loading={saving}>
|
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||||
{isNew ? "إضافة الفرع" : "حفظ التغييرات"}
|
{isNew ? "إضافة الفرع" : "حفظ التغييرات"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -98,21 +76,22 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
id={FORM_ID}
|
id={FORM_ID}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
noValidate
|
noValidate
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
>
|
>
|
||||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
{errors.name?.type === "manual" && (
|
||||||
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* name */}
|
{/* name */}
|
||||||
<Input
|
<Input
|
||||||
ref={firstInputRef}
|
|
||||||
label="اسم الفرع *"
|
label="اسم الفرع *"
|
||||||
value={form.name}
|
{...register("name")}
|
||||||
onChange={set("name")}
|
error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
|
||||||
error={errors.name}
|
|
||||||
placeholder="فرع الرياض"
|
placeholder="فرع الرياض"
|
||||||
autoComplete="organization"
|
autoComplete="organization"
|
||||||
|
autoFocus
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -121,9 +100,8 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
<Input
|
<Input
|
||||||
label="البريد الإلكتروني"
|
label="البريد الإلكتروني"
|
||||||
type="email"
|
type="email"
|
||||||
value={form.email}
|
{...register("email")}
|
||||||
onChange={set("email")}
|
error={errors.email?.message}
|
||||||
error={errors.email}
|
|
||||||
placeholder="branch@co.sa"
|
placeholder="branch@co.sa"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
@@ -131,9 +109,8 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
<Input
|
<Input
|
||||||
label="رقم الهاتف"
|
label="رقم الهاتف"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={form.phone}
|
{...register("phone")}
|
||||||
onChange={set("phone")}
|
error={errors.phone?.message}
|
||||||
error={errors.phone}
|
|
||||||
placeholder="+966 5x xxx xxxx"
|
placeholder="+966 5x xxx xxxx"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
@@ -144,17 +121,15 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Input
|
||||||
label="المدينة *"
|
label="المدينة *"
|
||||||
value={form.city}
|
{...register("city")}
|
||||||
onChange={set("city")}
|
error={errors.city?.message}
|
||||||
error={errors.city}
|
|
||||||
placeholder="الرياض"
|
placeholder="الرياض"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="الشارع *"
|
label="الشارع *"
|
||||||
value={form.street}
|
{...register("street")}
|
||||||
onChange={set("street")}
|
error={errors.street?.message}
|
||||||
error={errors.street}
|
|
||||||
placeholder="شارع الملك فهد"
|
placeholder="شارع الملك فهد"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
@@ -164,17 +139,15 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Input
|
||||||
label="المنطقة"
|
label="المنطقة"
|
||||||
value={form.state}
|
{...register("state")}
|
||||||
onChange={set("state")}
|
error={errors.state?.message}
|
||||||
error={errors.state}
|
|
||||||
placeholder="منطقة الرياض"
|
placeholder="منطقة الرياض"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="الحي"
|
label="الحي"
|
||||||
value={form.district}
|
{...register("district")}
|
||||||
onChange={set("district")}
|
error={errors.district?.message}
|
||||||
error={errors.district}
|
|
||||||
placeholder="حي العليا"
|
placeholder="حي العليا"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
@@ -184,25 +157,22 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Input
|
||||||
label="رقم المبنى"
|
label="رقم المبنى"
|
||||||
value={form.buildingNo}
|
{...register("buildingNo")}
|
||||||
onChange={set("buildingNo")}
|
error={errors.buildingNo?.message}
|
||||||
error={errors.buildingNo}
|
|
||||||
placeholder="1234"
|
placeholder="1234"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="رقم الوحدة"
|
label="رقم الوحدة"
|
||||||
value={form.unitNo}
|
{...register("unitNo")}
|
||||||
onChange={set("unitNo")}
|
error={errors.unitNo?.message}
|
||||||
error={errors.unitNo}
|
|
||||||
placeholder="5"
|
placeholder="5"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="الرمز البريدي"
|
label="الرمز البريدي"
|
||||||
value={form.zipCode}
|
{...register("zipCode")}
|
||||||
onChange={set("zipCode")}
|
error={errors.zipCode?.message}
|
||||||
error={errors.zipCode}
|
|
||||||
placeholder="12345"
|
placeholder="12345"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
@@ -211,9 +181,8 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
{/* country */}
|
{/* country */}
|
||||||
<Input
|
<Input
|
||||||
label="الدولة"
|
label="الدولة"
|
||||||
value={form.country}
|
{...register("country")}
|
||||||
onChange={set("country")}
|
error={errors.country?.message}
|
||||||
error={errors.country}
|
|
||||||
placeholder="SA"
|
placeholder="SA"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
@@ -222,18 +191,16 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Input
|
||||||
label="خط العرض (اختياري)"
|
label="خط العرض (اختياري)"
|
||||||
value={form.latitude}
|
{...register("latitude")}
|
||||||
onChange={set("latitude")}
|
error={errors.latitude?.message}
|
||||||
error={errors.latitude}
|
|
||||||
placeholder="24.7136"
|
placeholder="24.7136"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="خط الطول (اختياري)"
|
label="خط الطول (اختياري)"
|
||||||
value={form.longitude}
|
{...register("longitude")}
|
||||||
onChange={set("longitude")}
|
error={errors.longitude?.message}
|
||||||
error={errors.longitude}
|
|
||||||
placeholder="46.6753"
|
placeholder="46.6753"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useForm } from "react-hook-form";
|
||||||
import * as yup from "yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Modal } from "../UI";
|
import { Alert, Button, Input, Modal } from "../UI";
|
||||||
import {
|
import {
|
||||||
createMaintenanceSchema,
|
createMaintenanceSchema,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CarMaintenance,
|
CarMaintenance,
|
||||||
CreateMaintenancePayload,
|
CreateMaintenancePayload,
|
||||||
MaintenanceFormErrors,
|
|
||||||
UpdateMaintenancePayload,
|
UpdateMaintenancePayload,
|
||||||
} from "@/src/types/carMaintanance";
|
} from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
@@ -19,11 +18,8 @@ const FORM_ID = "maintenance-form";
|
|||||||
// ── Number coercion helper ────────────────────────────────────────────────────
|
// ── Number coercion helper ────────────────────────────────────────────────────
|
||||||
// The backend can return `cost` as a numeric string (common with Decimal
|
// The backend can return `cost` as a numeric string (common with Decimal
|
||||||
// columns getting JSON-serialized as strings), even though our TS type says
|
// columns getting JSON-serialized as strings), even though our TS type says
|
||||||
// `number`. If that untouched value round-trips back out on update without
|
// `number`. Coerce defensively both when hydrating the form AND right before
|
||||||
// ever passing through the `<input type="number">` onChange handler, it
|
// building the submit payload, so this can never slip through as a string.
|
||||||
// stays a string and fails the backend's strict `z.number()` check. Coerce
|
|
||||||
// defensively both when hydrating the form AND right before building the
|
|
||||||
// submit payload, so this can never happen regardless of the source.
|
|
||||||
|
|
||||||
function toNumberOrUndefined(v: unknown): number | undefined {
|
function toNumberOrUndefined(v: unknown): number | undefined {
|
||||||
if (v === undefined || v === null || v === "") return undefined;
|
if (v === undefined || v === null || v === "") return undefined;
|
||||||
@@ -31,30 +27,6 @@ function toNumberOrUndefined(v: unknown): number | undefined {
|
|||||||
return Number.isNaN(n) ? undefined : n;
|
return Number.isNaN(n) ? undefined : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── yup validation ────────────────────────────────────────────────────────────
|
|
||||||
// Checks the form values against the right schema and turns any problems
|
|
||||||
// into a simple field -> message map the inputs below can read from.
|
|
||||||
|
|
||||||
async function validate(
|
|
||||||
form: Partial<CreateMaintenancePayload>,
|
|
||||||
isNew: boolean,
|
|
||||||
): Promise<MaintenanceFormErrors> {
|
|
||||||
const schema = isNew ? createMaintenanceSchema : updateMaintenanceSchema;
|
|
||||||
try {
|
|
||||||
await schema.validate(form, { abortEarly: false });
|
|
||||||
return {};
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof yup.ValidationError) {
|
|
||||||
return err.inner.reduce<MaintenanceFormErrors>((acc, e) => {
|
|
||||||
const field = e.path as keyof MaintenanceFormErrors;
|
|
||||||
if (field && !acc[field]) acc[field] = e.message;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Date helper ───────────────────────────────────────────────────────────────
|
// ── Date helper ───────────────────────────────────────────────────────────────
|
||||||
// <input type="date"> gives "YYYY-MM-DD"; the backend expects full ISO-8601.
|
// <input type="date"> gives "YYYY-MM-DD"; the backend expects full ISO-8601.
|
||||||
|
|
||||||
@@ -64,6 +36,19 @@ function toIsoDateTime(val: string): string {
|
|||||||
return `${val}T00:00:00.000Z`;
|
return `${val}T00:00:00.000Z`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Form values shape ────────────────────────────────────────────────────────
|
||||||
|
// `cost` stays number | undefined (via setValueAs) so an empty input maps to
|
||||||
|
// `undefined` rather than NaN; startAt/endAt stay as raw "YYYY-MM-DD" strings
|
||||||
|
// while typed — yup's dateString test accepts that format directly — and are
|
||||||
|
// only converted to full ISO-8601 right before the payload is built.
|
||||||
|
|
||||||
|
interface MaintenanceFormValues {
|
||||||
|
reason: string;
|
||||||
|
cost: number | undefined;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface CarMaintenanceFormModalProps {
|
interface CarMaintenanceFormModalProps {
|
||||||
@@ -88,64 +73,52 @@ export function CarMaintenanceFormModal({
|
|||||||
}: CarMaintenanceFormModalProps) {
|
}: CarMaintenanceFormModalProps) {
|
||||||
const isNew = editRecord === null;
|
const isNew = editRecord === null;
|
||||||
|
|
||||||
// ── Form state ─────────────────────────────────────────────────────────────
|
const {
|
||||||
const [reason, setReason] = useState(editRecord?.reason ?? "");
|
register,
|
||||||
// Coerced defensively: editRecord.cost may arrive as a numeric string from
|
handleSubmit,
|
||||||
// the backend (e.g. a Decimal column serialized to JSON as "150").
|
setError,
|
||||||
const [cost, setCost] = useState<number | undefined>(toNumberOrUndefined(editRecord?.cost));
|
formState: { errors, isSubmitting },
|
||||||
const [startAt, setStartAt] = useState(editRecord?.startAt?.slice(0, 10) ?? "");
|
} = useForm<MaintenanceFormValues>({
|
||||||
const [endAt, setEndAt] = useState(editRecord?.endAt?.slice(0, 10) ?? "");
|
// Cast the schema itself and pin the generic explicitly — create/update
|
||||||
|
// schemas differ structurally in which fields are required, so yup can't
|
||||||
|
// unify them into MaintenanceFormValues on its own.
|
||||||
|
resolver: yupResolver<MaintenanceFormValues>(
|
||||||
|
(isNew ? createMaintenanceSchema : updateMaintenanceSchema) as any,
|
||||||
|
),
|
||||||
|
defaultValues: {
|
||||||
|
reason: editRecord?.reason ?? "",
|
||||||
|
// Coerced defensively: editRecord.cost may arrive as a numeric string
|
||||||
|
// from the backend (e.g. a Decimal column serialized to JSON as "150").
|
||||||
|
cost: toNumberOrUndefined(editRecord?.cost),
|
||||||
|
startAt: editRecord?.startAt?.slice(0, 10) ?? "",
|
||||||
|
endAt: editRecord?.endAt?.slice(0, 10) ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const [errors, setErrors] = useState<MaintenanceFormErrors>({});
|
const costField = register("cost", {
|
||||||
const [saving, setSaving] = useState(false);
|
setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)),
|
||||||
const [apiError, setApiError] = useState("");
|
});
|
||||||
const firstRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
|
||||||
|
|
||||||
const parseNum = (v: string): number | undefined =>
|
|
||||||
v.trim() === "" ? undefined : Number(v);
|
|
||||||
|
|
||||||
const clearFieldError = (field: keyof MaintenanceFormErrors) =>
|
|
||||||
setErrors((p) => ({ ...p, [field]: undefined }));
|
|
||||||
|
|
||||||
// ── Submit ────────────────────────────────────────────────────────────────
|
// ── Submit ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const submitHandler = async (data: MaintenanceFormValues) => {
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Belt-and-braces: re-coerce cost right before it's used, in case it
|
// Belt-and-braces: re-coerce cost right before it's used, in case it
|
||||||
// somehow slipped back into a string (e.g. untouched value hydrated
|
// somehow slipped back into a string.
|
||||||
// from a record whose `cost` came back as a numeric string).
|
const safeCost = toNumberOrUndefined(data.cost);
|
||||||
const safeCost = toNumberOrUndefined(cost);
|
|
||||||
|
|
||||||
// Build a snapshot for yup — include everything so optional rules also run.
|
// Dates go out as full ISO-8601 strings, and cost always goes out as a
|
||||||
const formSnapshot: Partial<CreateMaintenancePayload> = {
|
// real number, never a string.
|
||||||
reason,
|
const raw: Record<string, unknown> = { reason: data.reason, cost: safeCost };
|
||||||
cost: safeCost,
|
if (data.startAt) raw.startAt = toIsoDateTime(data.startAt);
|
||||||
...(startAt && { startAt }),
|
if (data.endAt) raw.endAt = toIsoDateTime(data.endAt);
|
||||||
...(endAt && { endAt }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const errs = await validate(formSnapshot, isNew);
|
|
||||||
if (Object.keys(errs).length) {
|
|
||||||
setErrors(errs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the final payload — dates go out as full ISO-8601 strings, and
|
|
||||||
// cost always goes out as a real number, never a string.
|
|
||||||
const raw: Record<string, unknown> = { reason, cost: safeCost };
|
|
||||||
if (startAt) raw.startAt = toIsoDateTime(startAt);
|
|
||||||
if (endAt) raw.endAt = toIsoDateTime(endAt);
|
|
||||||
|
|
||||||
const payload = raw as unknown as CreateMaintenancePayload;
|
const payload = raw as unknown as CreateMaintenancePayload;
|
||||||
setSaving(true);
|
|
||||||
setApiError("");
|
|
||||||
const ok = await onSubmit(payload, isNew);
|
const ok = await onSubmit(payload, isNew);
|
||||||
setSaving(false);
|
if (ok) {
|
||||||
if (ok) onClose();
|
onClose();
|
||||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
} else {
|
||||||
|
setError("reason", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
@@ -159,10 +132,10 @@ export function CarMaintenanceFormModal({
|
|||||||
title={carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
|
title={carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" type="button" onClick={onClose} disabled={saving}>
|
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
|
||||||
إلغاء
|
إلغاء
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form={FORM_ID} loading={saving}>
|
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||||
{isNew ? "إضافة السجل" : "حفظ التغييرات"}
|
{isNew ? "إضافة السجل" : "حفظ التغييرات"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -170,21 +143,22 @@ export function CarMaintenanceFormModal({
|
|||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
id={FORM_ID}
|
id={FORM_ID}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
noValidate
|
noValidate
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
{errors.reason?.type === "manual" && (
|
||||||
|
<Alert type="error" message={errors.reason.message ?? ""} onClose={() => {}} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
ref={firstRef}
|
|
||||||
label="سبب الصيانة *"
|
label="سبب الصيانة *"
|
||||||
value={reason}
|
{...register("reason")}
|
||||||
onChange={(e) => { setReason(e.target.value); clearFieldError("reason"); }}
|
error={errors.reason && errors.reason.type !== "manual" ? errors.reason.message : undefined}
|
||||||
placeholder="تغيير زيت المحرك"
|
placeholder="تغيير زيت المحرك"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
error={errors.reason}
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
@@ -192,28 +166,25 @@ export function CarMaintenanceFormModal({
|
|||||||
label="التكلفة (ر.س) *"
|
label="التكلفة (ر.س) *"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
value={cost ?? ""}
|
{...costField}
|
||||||
onChange={(e) => { setCost(parseNum(e.target.value)); clearFieldError("cost"); }}
|
error={errors.cost?.message}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
error={errors.cost}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="تاريخ البدء *"
|
label="تاريخ البدء *"
|
||||||
type="date"
|
type="date"
|
||||||
value={startAt}
|
{...register("startAt")}
|
||||||
onChange={(e) => { setStartAt(e.target.value); clearFieldError("startAt"); }}
|
error={errors.startAt?.message}
|
||||||
error={errors.startAt}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="تاريخ الانتهاء"
|
label="تاريخ الانتهاء"
|
||||||
type="date"
|
type="date"
|
||||||
value={endAt}
|
{...register("endAt")}
|
||||||
onChange={(e) => { setEndAt(e.target.value); clearFieldError("endAt"); }}
|
error={errors.endAt?.message}
|
||||||
error={errors.endAt}
|
|
||||||
hint="اتركه فارغاً إذا كانت الصيانة ما زالت جارية."
|
hint="اتركه فارغاً إذا كانت الصيانة ما زالت جارية."
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Spinner ,Toast} from "../UI";
|
import { Button, Toast } from "../UI";
|
||||||
import { tripService } from "@/src/services/trip.service";
|
import { tripService } from "@/src/services/trip.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import type { TripNotification } from "@/src/hooks/useTrip";
|
import type { TripNotification } from "@/src/hooks/useTrip";
|
||||||
@@ -78,30 +78,9 @@ export function TripReportPanel({ tripId }: TripReportPanelProps) {
|
|||||||
إنشاء تقرير الرحلة
|
إنشاء تقرير الرحلة
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<Button type="button" onClick={handleGenerate} loading={loading}>
|
||||||
type="button"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
height: 38,
|
|
||||||
padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
background: loading ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
|
||||||
cursor: loading ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading && <Spinner size="sm" className="text-white" />}
|
|
||||||
{loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"}
|
{loading ? "جارٍ الإنشاء…" : "إنشاء تقرير البيان"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toast
|
<Toast
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Spinner } from "../UI";
|
import { Alert, Button, Modal, Spinner } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { userService } from "@/src/services/user.service";
|
import { userService } from "@/src/services/user.service";
|
||||||
import type { UserDetail } from "@/src/types/user";
|
import type { UserDetail } from "@/src/types/user";
|
||||||
@@ -12,6 +12,8 @@ interface UserDetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── small helper components ───────────────────────────────────────────────────
|
// ── small helper components ───────────────────────────────────────────────────
|
||||||
|
// (No equivalents in the shared UI kit — DetailRow/StatusBadge/Avatar are
|
||||||
|
// purpose-built layouts, not generic form/action controls, so they stay custom.)
|
||||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -29,6 +31,9 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||||
|
// pill (no dot indicator), and the pulse-dot is the whole point of this status
|
||||||
|
// chip's design.
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -68,13 +73,6 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 user details on mount
|
// fetch user details on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -98,60 +96,18 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
|||||||
|
|
||||||
// ── render ────────────────────────────────────────────────────────────────
|
// ── render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={user?.name ?? "عرض المستخدم"}
|
||||||
style={{
|
subtitle="بيانات المستخدم"
|
||||||
position: "fixed", inset: 0, zIndex: 55,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
size="md"
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
footer={
|
||||||
}}
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
إغلاق
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<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="detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
{user?.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 */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||||
@@ -161,17 +117,7 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* error */}
|
{/* error */}
|
||||||
{!loading && error && (
|
{!loading && error && <Alert type="error" message={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 */}
|
{/* content */}
|
||||||
{!loading && user && (
|
{!loading && user && (
|
||||||
@@ -211,31 +157,6 @@ export function UserDetailModal({ userId, onClose }: UserDetailModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
{/* ── 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useForm } from "react-hook-form";
|
||||||
import * as yup from "yup";
|
import type { Resolver } from "react-hook-form";
|
||||||
import { Alert, Button, Input, Select, Spinner } from "../UI";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import { Alert, Button, Input, Select } from "../UI";
|
||||||
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
|
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
|
||||||
import type { Branch } from "@/src/types/branch";
|
import type { Branch } from "@/src/types/branch";
|
||||||
import type { Role } from "@/src/types/role";
|
import type { Role } from "@/src/types/role";
|
||||||
import type { FormErrors, User, UserFormData } from "@/src/types/user";
|
import type { User, UserFormData } from "@/src/types/user";
|
||||||
|
|
||||||
// ── yup validation ────────────────────────────────────────────────────────────
|
|
||||||
async function validate(data: UserFormData, isNew: boolean): Promise<FormErrors> {
|
|
||||||
const schema = isNew ? createUserSchema : updateUserSchema;
|
|
||||||
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 ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
interface UserFormModalProps {
|
interface UserFormModalProps {
|
||||||
@@ -39,50 +22,43 @@ interface UserFormModalProps {
|
|||||||
export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }: UserFormModalProps) {
|
export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }: UserFormModalProps) {
|
||||||
const isNew = editUser === null;
|
const isNew = editUser === null;
|
||||||
|
|
||||||
const [form, setForm] = useState<UserFormData>({
|
// Custom resolver wrapper — on edit, an empty password field must be
|
||||||
|
// treated as "not provided" (skip the min(8) rule entirely) rather than
|
||||||
|
// validated as an empty string, exactly like the old validate() call did
|
||||||
|
// by stripping `password` from the object before running the schema.
|
||||||
|
const resolver: Resolver<UserFormData> = async (values, context, options) => {
|
||||||
|
const schema = isNew ? createUserSchema : updateUserSchema;
|
||||||
|
const data = !isNew && !values.password ? { ...values, password: undefined } : values;
|
||||||
|
return yupResolver<UserFormData>(schema as any)(data as UserFormData, context, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<UserFormData>({
|
||||||
|
resolver,
|
||||||
|
defaultValues: {
|
||||||
name: editUser?.name ?? "",
|
name: editUser?.name ?? "",
|
||||||
email: editUser?.email ?? "",
|
email: editUser?.email ?? "",
|
||||||
phone: editUser?.phone ?? "",
|
phone: editUser?.phone ?? "",
|
||||||
password: "",
|
password: "",
|
||||||
roleId: editUser?.role?.id ?? "",
|
roleId: editUser?.role?.id ?? "",
|
||||||
branchId: editUser?.branch?.id ?? "",
|
branchId: editUser?.branch?.id ?? "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [apiError, setApiError] = useState("");
|
|
||||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => { firstInputRef.current?.focus(); }, []);
|
const submitHandler = async (data: UserFormData) => {
|
||||||
useEffect(() => {
|
const payload: Partial<UserFormData> = { ...data };
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const set = (field: keyof UserFormData) =>
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
||||||
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(
|
|
||||||
!isNew && !form.password
|
|
||||||
? { ...form, password: undefined as unknown as string }
|
|
||||||
: form,
|
|
||||||
isNew,
|
|
||||||
);
|
|
||||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
|
||||||
setSaving(true);
|
|
||||||
setApiError("");
|
|
||||||
const payload: Partial<UserFormData> = { ...form };
|
|
||||||
if (!isNew && !payload.password) delete payload.password;
|
if (!isNew && !payload.password) delete payload.password;
|
||||||
|
|
||||||
const ok = await onSubmit(payload as UserFormData, isNew);
|
const ok = await onSubmit(payload as UserFormData, isNew);
|
||||||
setSaving(false);
|
if (ok) {
|
||||||
if (ok) onClose();
|
onClose();
|
||||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
} else {
|
||||||
|
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,18 +109,19 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<form onSubmit={handleSubmit(submitHandler)} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
{errors.name?.type === "manual" && (
|
||||||
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* name */}
|
{/* name */}
|
||||||
<Input
|
<Input
|
||||||
ref={firstInputRef}
|
|
||||||
label="الاسم الكامل *"
|
label="الاسم الكامل *"
|
||||||
value={form.name}
|
{...register("name")}
|
||||||
onChange={set("name")}
|
error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
|
||||||
error={errors.name}
|
|
||||||
placeholder="أحمد الرشيدي"
|
placeholder="أحمد الرشيدي"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
|
autoFocus
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -153,9 +130,8 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
<Input
|
<Input
|
||||||
label="البريد الإلكتروني"
|
label="البريد الإلكتروني"
|
||||||
type="email"
|
type="email"
|
||||||
value={form.email}
|
{...register("email")}
|
||||||
onChange={set("email")}
|
error={errors.email?.message}
|
||||||
error={errors.email}
|
|
||||||
placeholder="ahmed@co.sa"
|
placeholder="ahmed@co.sa"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
@@ -163,9 +139,8 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
<Input
|
<Input
|
||||||
label="رقم الهاتف *"
|
label="رقم الهاتف *"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={form.phone}
|
{...register("phone")}
|
||||||
onChange={set("phone")}
|
error={errors.phone?.message}
|
||||||
error={errors.phone}
|
|
||||||
placeholder="+966 5x xxx xxxx"
|
placeholder="+966 5x xxx xxxx"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
@@ -176,9 +151,8 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
<Input
|
<Input
|
||||||
label={isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"}
|
label={isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"}
|
||||||
type="password"
|
type="password"
|
||||||
value={form.password}
|
{...register("password")}
|
||||||
onChange={set("password")}
|
error={errors.password?.message}
|
||||||
error={errors.password}
|
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
autoComplete={isNew ? "new-password" : "off"}
|
autoComplete={isNew ? "new-password" : "off"}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
@@ -188,9 +162,8 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Select
|
<Select
|
||||||
label="الدور *"
|
label="الدور *"
|
||||||
value={form.roleId}
|
{...register("roleId")}
|
||||||
onChange={set("roleId")}
|
error={errors.roleId?.message}
|
||||||
error={errors.roleId}
|
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<option value="">اختر الدور</option>
|
<option value="">اختر الدور</option>
|
||||||
@@ -198,9 +171,8 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
label="الفرع *"
|
label="الفرع *"
|
||||||
value={form.branchId}
|
{...register("branchId")}
|
||||||
onChange={set("branchId")}
|
error={errors.branchId?.message}
|
||||||
error={errors.branchId}
|
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<option value="">اختر الفرع</option>
|
<option value="">اختر الفرع</option>
|
||||||
@@ -210,11 +182,11 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
|
|
||||||
{/* actions */}
|
{/* 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" variant="secondary" onClick={onClose} disabled={saving}>
|
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
إلغاء
|
إلغاء
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" loading={saving}>
|
<Button type="submit" variant="primary" loading={isSubmitting}>
|
||||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة المستخدم" : "حفظ التغييرات"}
|
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة المستخدم" : "حفظ التغييرات"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../UI";
|
import { Badge, Button, EmptyState, Spinner } from "../UI";
|
||||||
import type { User } from "@/src/types/user";
|
import type { User } from "@/src/types/user";
|
||||||
|
|
||||||
// ── role badge ───────────────────────────────────────────────────────────────
|
|
||||||
function RoleBadge({ name }: { name?: string }) {
|
|
||||||
const isAdmin = name === "مدير النظام";
|
|
||||||
return (
|
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", borderRadius: "var(--radius-full)", border: isAdmin ? "1px solid #BFDBFE" : "1px solid var(--color-border)", background: isAdmin ? "#EFF6FF" : "var(--color-surface-muted)", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: isAdmin ? "#1D4ED8" : "var(--color-text-muted)" }}>
|
|
||||||
{name ?? "—"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
// ── status badge ─────────────────────────────────────────────────────────────
|
||||||
|
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||||
|
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||||
@@ -24,6 +16,11 @@ function StatusBadge({ active }: { active: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── icon button ──────────────────────────────────────────────────────────────
|
// ── icon button ──────────────────────────────────────────────────────────────
|
||||||
|
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||||
|
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||||
|
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
|
||||||
|
// square icon chip. Forcing it here would collapse the view/edit/delete
|
||||||
|
// green/blue/red color-coding into a single variant and misshape the button.
|
||||||
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
||||||
@@ -82,17 +79,17 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
|
|||||||
</div>
|
</div>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="👥"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون لعرضهم."}
|
||||||
</p>
|
action={
|
||||||
{!search && (
|
!search && (
|
||||||
<button type="button" onClick={onAddFirst}
|
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||||
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
|
||||||
أضف أول مستخدم
|
أضف أول مستخدم
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* data rows */
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
@@ -110,7 +107,7 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
|
|||||||
</div>
|
</div>
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{u.branch?.name ?? "—"}</span>
|
<span style={{ color: "var(--color-text-secondary)" }}>{u.branch?.name ?? "—"}</span>
|
||||||
<RoleBadge name={u.role?.name} />
|
<Badge label={u.role?.name ?? "—"} color={u.role?.name === "مدير النظام" ? "indigo" : "slate"} />
|
||||||
<StatusBadge active={u.isActive} />
|
<StatusBadge active={u.isActive} />
|
||||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
@@ -150,15 +147,24 @@ export function UserTable({ users, loading, search, page, pages, onEdit, onDelet
|
|||||||
صفحة <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>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
<Button
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
type="button"
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
variant="secondary"
|
||||||
].map(btn => (
|
size="sm"
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
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)" }}>
|
disabled={page === 1}
|
||||||
{btn.label}
|
>
|
||||||
</button>
|
السابق
|
||||||
))}
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Spinner } from "../../UI";
|
import { Alert, Button, Modal, Spinner } from "../../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { archivedUserService } from "@/src/services/archive/archivedUser.service";
|
import { archivedUserService } from "@/src/services/archive/archivedUser.service";
|
||||||
import type { ArchivedUser } from "@/src/types/user";
|
import type { ArchivedUser } from "@/src/types/user";
|
||||||
@@ -12,6 +12,8 @@ interface ArchivedUserDetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── small helper components ───────────────────────────────────────────────────
|
// ── small helper components ───────────────────────────────────────────────────
|
||||||
|
// (No equivalents in the shared UI kit — DetailRow/StatusBadge/Avatar are
|
||||||
|
// purpose-built layouts, not generic form/action controls, so they stay custom.)
|
||||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -29,6 +31,9 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||||
|
// pill (no dot indicator), and the pulse-dot is the whole point of this status
|
||||||
|
// chip's design.
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -68,13 +73,6 @@ export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailM
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 archived user details on mount
|
// fetch archived user details on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -98,60 +96,18 @@ useEffect(() => {
|
|||||||
|
|
||||||
// ── render ────────────────────────────────────────────────────────────────
|
// ── render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archived-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={user?.name ?? "عرض المستخدم"}
|
||||||
style={{
|
subtitle="مستخدم مؤرشف"
|
||||||
position: "fixed", inset: 0, zIndex: 55,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
size="md"
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
footer={
|
||||||
}}
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
إغلاق
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<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: "#EA580C", fontWeight: 600, margin: 0 }}>
|
|
||||||
مستخدم مؤرشف
|
|
||||||
</p>
|
|
||||||
<h2 id="archived-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
{user?.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 */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||||
@@ -161,17 +117,7 @@ useEffect(() => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* error */}
|
{/* error */}
|
||||||
{!loading && error && (
|
{!loading && error && <Alert type="error" message={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 */}
|
{/* content */}
|
||||||
{!loading && user && (
|
{!loading && user && (
|
||||||
@@ -205,31 +151,6 @@ useEffect(() => {
|
|||||||
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
|
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
{/* ── 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Button, EmptyState, Spinner } from "../../UI";
|
||||||
import type { ArchivedUser } from "@/src/types/user";
|
import type { ArchivedUser } from "@/src/types/user";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
// ── status badge ─────────────────────────────────────────────────────────────
|
||||||
|
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||||
|
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||||
@@ -14,6 +16,11 @@ function StatusBadge({ active }: { active: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── icon button ──────────────────────────────────────────────────────────────
|
// ── icon button ──────────────────────────────────────────────────────────────
|
||||||
|
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||||
|
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||||
|
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
|
||||||
|
// square icon chip. Forcing it here would misshape the button and drop the
|
||||||
|
// semantic green "view" color.
|
||||||
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
||||||
@@ -68,11 +75,10 @@ export function ArchivedUserTable({ users, loading, search, page, pages, onView,
|
|||||||
</div>
|
</div>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="🗄️"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* data rows */
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
@@ -117,15 +123,24 @@ export function ArchivedUserTable({ users, loading, search, page, pages, onView,
|
|||||||
صفحة <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>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
<Button
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
type="button"
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
variant="secondary"
|
||||||
].map(btn => (
|
size="sm"
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
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)" }}>
|
disabled={page === 1}
|
||||||
{btn.label}
|
>
|
||||||
</button>
|
السابق
|
||||||
))}
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Spinner } from "../UI";
|
import { Alert, Button, Modal, Select, Spinner } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { roleService } from "@/src/services/role.service";
|
import { roleService } from "@/src/services/role.service";
|
||||||
import { Permission, Role } from "@/src/types/role";
|
import { Permission, Role } from "@/src/types/role";
|
||||||
@@ -14,6 +14,7 @@ interface RoleDetailModalProps {
|
|||||||
onRemove: (roleId: string, permissionId: string) => Promise<boolean>;
|
onRemove: (roleId: string, permissionId: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (No shared-UI equivalents — purpose-built layouts, not generic controls.)
|
||||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -30,6 +31,8 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||||
|
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -54,12 +57,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
const [pendingPermId, setPendingPermId] = useState("");
|
const [pendingPermId, setPendingPermId] = useState("");
|
||||||
const [mutating, setMutating] = useState(false);
|
const [mutating, setMutating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const loadRole = async () => {
|
const loadRole = async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
@@ -115,44 +112,18 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="role-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={role?.name ?? "عرض الدور"}
|
||||||
style={{
|
subtitle="تفاصيل الدور"
|
||||||
position: "fixed", inset: 0, zIndex: 55,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
size="md"
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
footer={
|
||||||
}}
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
إغلاق
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<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",
|
|
||||||
}}>
|
|
||||||
<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="role-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
{role?.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>
|
|
||||||
|
|
||||||
<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)" }}>
|
<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" />
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
@@ -160,11 +131,7 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && error && (
|
{!loading && error && <Alert type="error" message={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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && role && (
|
{!loading && role && (
|
||||||
<div dir="rtl">
|
<div dir="rtl">
|
||||||
@@ -197,38 +164,29 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{assignablePermissions.length > 0 && (
|
{assignablePermissions.length > 0 && (
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
<div style={{ display: "flex", gap: 8, marginBottom: 12, alignItems: "flex-start" }}>
|
||||||
<select
|
<div style={{ flex: 1 }}>
|
||||||
|
<Select
|
||||||
value={pendingPermId}
|
value={pendingPermId}
|
||||||
onChange={(e) => setPendingPermId(e.target.value)}
|
onChange={(e) => setPendingPermId(e.target.value)}
|
||||||
disabled={mutating}
|
disabled={mutating}
|
||||||
style={{
|
dir="rtl"
|
||||||
flex: 1, height: 36, borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)", fontSize: 12,
|
|
||||||
padding: "0 0.5rem", fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)", background: "var(--color-surface)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="">اختر صلاحية لإضافتها…</option>
|
<option value="">اختر صلاحية لإضافتها…</option>
|
||||||
{assignablePermissions.map((p) => (
|
{assignablePermissions.map((p) => (
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
<button
|
</div>
|
||||||
type="button" onClick={handleAssign} disabled={!pendingPermId || mutating}
|
<Button
|
||||||
style={{
|
type="button"
|
||||||
height: 36, padding: "0 0.875rem", borderRadius: "var(--radius-md)",
|
size="sm"
|
||||||
border: "none", background: "var(--color-brand-600)", color: "#FFF",
|
onClick={handleAssign}
|
||||||
fontSize: 12, fontWeight: 700,
|
disabled={!pendingPermId}
|
||||||
cursor: pendingPermId && !mutating ? "pointer" : "not-allowed",
|
loading={mutating}
|
||||||
opacity: !pendingPermId || mutating ? 0.6 : 1,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
display: "flex", alignItems: "center", gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{mutating && <Spinner size="sm" className="text-white" />}
|
|
||||||
إضافة
|
إضافة
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -262,18 +220,6 @@ export function RoleDetailModal({ roleId, permissions, onClose, onAssign, onRemo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import * as yup from "yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Modal, Textarea } from "../UI";
|
import { Alert, Button, Input, Modal, Textarea } from "../UI";
|
||||||
import { createRoleSchema, updateRoleSchema, type RoleFormErrors } from "@/src/validations/role.validator";
|
import { createRoleSchema, updateRoleSchema } from "@/src/validations/role.validator";
|
||||||
import { Permission, Role, RoleFormData } from "@/src/types/role";
|
import { Permission, Role, RoleFormData } from "@/src/types/role";
|
||||||
|
|
||||||
// ── Validation ────────────────────────────────────────────────────────────────
|
const FORM_ID = "role-form";
|
||||||
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 {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Permission checkbox group ─────────────────────────────────────────────────
|
// ── Permission checkbox group ─────────────────────────────────────────────────
|
||||||
function PermissionGroup({
|
function PermissionGroup({
|
||||||
@@ -82,46 +66,32 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
|||||||
|
|
||||||
const currentPermIds = editRole?.permissions?.map(p => p.permission.id) ?? [];
|
const currentPermIds = editRole?.permissions?.map(p => p.permission.id) ?? [];
|
||||||
|
|
||||||
const [form, setForm] = useState<RoleFormData>({
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setError,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<RoleFormData>({
|
||||||
|
// Cast the schema itself and pin the generic explicitly — create/update
|
||||||
|
// schemas differ structurally in which fields are required, so yup can't
|
||||||
|
// unify them into RoleFormData on its own.
|
||||||
|
resolver: yupResolver<RoleFormData>((isNew ? createRoleSchema : updateRoleSchema) as any),
|
||||||
|
defaultValues: {
|
||||||
name: editRole?.name ?? "",
|
name: editRole?.name ?? "",
|
||||||
description: editRole?.description ?? "",
|
description: editRole?.description ?? "",
|
||||||
permissionIds: currentPermIds,
|
permissionIds: currentPermIds,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<RoleFormErrors>({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [apiError, setApiError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
const submitHandler = async (data: RoleFormData) => {
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
const ok = await onSubmit(data, isNew);
|
||||||
window.addEventListener("keydown", handler);
|
if (ok) {
|
||||||
return () => window.removeEventListener("keydown", handler);
|
onClose();
|
||||||
}, [onClose]);
|
} else {
|
||||||
|
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||||
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) => {
|
|
||||||
setForm(p => ({
|
|
||||||
...p,
|
|
||||||
permissionIds: p.permissionIds.includes(id)
|
|
||||||
? p.permissionIds.filter(x => x !== id)
|
|
||||||
: [...p.permissionIds, id],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
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("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,30 +103,31 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
|||||||
size="md"
|
size="md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>
|
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
إلغاء
|
إلغاء
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="role-form" loading={saving}>
|
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||||
{isNew ? "إنشاء الدور" : "حفظ التغييرات"}
|
{isNew ? "إنشاء الدور" : "حفظ التغييرات"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
id="role-form"
|
id={FORM_ID}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
noValidate
|
noValidate
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
>
|
>
|
||||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
{errors.name?.type === "manual" && (
|
||||||
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div dir="rtl">
|
<div dir="rtl">
|
||||||
<Input
|
<Input
|
||||||
label="اسم الدور *"
|
label="اسم الدور *"
|
||||||
value={form.name}
|
{...register("name")}
|
||||||
onChange={set("name")}
|
error={errors.name && errors.name.type !== "manual" ? errors.name.message : undefined}
|
||||||
placeholder="مثال: مدير الفروع"
|
placeholder="مثال: مدير الفروع"
|
||||||
error={errors.name}
|
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -165,29 +136,39 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
|||||||
<div dir="rtl">
|
<div dir="rtl">
|
||||||
<Textarea
|
<Textarea
|
||||||
label="الوصف"
|
label="الوصف"
|
||||||
value={form.description}
|
{...register("description")}
|
||||||
onChange={set("description")}
|
|
||||||
placeholder="وصف مختصر لمهام هذا الدور…"
|
placeholder="وصف مختصر لمهام هذا الدور…"
|
||||||
error={errors.description}
|
error={errors.description?.message}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Permissions */}
|
{/* Permissions — bound via Controller since it's a custom checkbox
|
||||||
|
group, not a plain input/select/textarea. */}
|
||||||
{permissions.length > 0 && (
|
{permissions.length > 0 && (
|
||||||
|
<Controller
|
||||||
|
name="permissionIds"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<div dir="rtl">
|
<div dir="rtl">
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
|
||||||
الصلاحيات
|
الصلاحيات
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: permissions.map(x => x.id) }))}
|
<Button
|
||||||
style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
type="button"
|
||||||
|
onClick={() => setValue("permissionIds", permissions.map(x => x.id))}
|
||||||
|
style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
تحديد الكل
|
تحديد الكل
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: [] }))}
|
<Button
|
||||||
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
type="button"
|
||||||
|
onClick={() => setValue("permissionIds", [])}
|
||||||
|
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
إلغاء الكل
|
إلغاء الكل
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,16 +180,27 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
|||||||
}}>
|
}}>
|
||||||
{Object.entries(grouped).map(([module, perms]) => (
|
{Object.entries(grouped).map(([module, perms]) => (
|
||||||
<PermissionGroup
|
<PermissionGroup
|
||||||
key={module} module={module} perms={perms}
|
key={module}
|
||||||
selected={form.permissionIds} onToggle={togglePerm}
|
module={module}
|
||||||
|
perms={perms}
|
||||||
|
selected={field.value}
|
||||||
|
onToggle={(id) =>
|
||||||
|
field.onChange(
|
||||||
|
field.value.includes(id)
|
||||||
|
? field.value.filter((x) => x !== id)
|
||||||
|
: [...field.value, id],
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 6 }}>
|
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 6 }}>
|
||||||
{form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
|
{field.value.length} صلاحية محددة من أصل {permissions.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Role } from "@/src/types/role";
|
import { Role } from "@/src/types/role";
|
||||||
import { Spinner } from "../UI";
|
import { Button, EmptyState, Spinner } from "../UI";
|
||||||
|
|
||||||
|
|
||||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||||
|
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||||
|
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -23,6 +24,11 @@ function StatusBadge({ active }: { active: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Icon button ───────────────────────────────────────────────────────────────
|
// ── Icon button ───────────────────────────────────────────────────────────────
|
||||||
|
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||||
|
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||||
|
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
|
||||||
|
// square icon chip. Forcing it here would misshape the button and collapse
|
||||||
|
// the view/edit/delete green/blue/red color-coding into one variant.
|
||||||
function IconBtn({ onClick, title, color, bg, borderColor, children }: {
|
function IconBtn({ onClick, title, color, bg, borderColor, children }: {
|
||||||
onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode;
|
onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -90,21 +96,18 @@ export function RoleTable({
|
|||||||
</div>
|
</div>
|
||||||
) : roles.length === 0 ? (
|
) : roles.length === 0 ? (
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<div style={{ fontSize: 40, marginBottom: 12 }}>🛡️</div>
|
icon="🛡️"
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: "0 0 8px" }}>
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"}
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار لعرضها"}
|
description={!search ? "ابدأ بإنشاء أول دور في النظام" : undefined}
|
||||||
</p>
|
action={
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: "0 0 16px" }}>
|
!search && (
|
||||||
{!search && "ابدأ بإنشاء أول دور في النظام"}
|
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||||
</p>
|
|
||||||
{!search && (
|
|
||||||
<button type="button" onClick={onAddFirst}
|
|
||||||
style={{ fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
|
||||||
إضافة أول دور
|
إضافة أول دور
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* Data rows */
|
/* Data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
@@ -166,15 +169,24 @@ export function RoleTable({
|
|||||||
صفحة <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>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
<Button
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
type="button"
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
variant="secondary"
|
||||||
].map(btn => (
|
size="sm"
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
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 }}>
|
disabled={page === 1}
|
||||||
{btn.label}
|
>
|
||||||
</button>
|
السابق
|
||||||
))}
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Spinner } from "../../UI";
|
import { Alert, Button, Modal, Spinner } from "../../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { archivedRoleService } from "@/src/services/archive/archivedRole.service";
|
import { archivedRoleService } from "@/src/services/archive/archivedRole.service";
|
||||||
import type { ArchivedRole } from "@/src/types/role";
|
import type { ArchivedRole } from "@/src/types/role";
|
||||||
@@ -12,6 +12,8 @@ interface ArchivedRoleDetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── small helper components ───────────────────────────────────────────────────
|
// ── small helper components ───────────────────────────────────────────────────
|
||||||
|
// (No equivalents in the shared UI kit — purpose-built layouts, not generic
|
||||||
|
// form/action controls, so they stay custom.)
|
||||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -29,6 +31,8 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||||
|
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -69,13 +73,6 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 archived role details on mount
|
// fetch archived role details on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -99,60 +96,18 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
|||||||
|
|
||||||
// ── render ────────────────────────────────────────────────────────────────
|
// ── render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archived-role-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={role?.name ?? "عرض الدور"}
|
||||||
style={{
|
subtitle="دور مؤرشف"
|
||||||
position: "fixed", inset: 0, zIndex: 55,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
size="md"
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
footer={
|
||||||
}}
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
إغلاق
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<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: "#EA580C", fontWeight: 600, margin: 0 }}>
|
|
||||||
دور مؤرشف
|
|
||||||
</p>
|
|
||||||
<h2 id="archived-role-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
{role?.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 */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
||||||
@@ -162,17 +117,7 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* error — fallback UI on API failure */}
|
{/* error — fallback UI on API failure */}
|
||||||
{!loading && error && (
|
{!loading && error && <Alert type="error" message={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 */}
|
{/* content */}
|
||||||
{!loading && role && (
|
{!loading && role && (
|
||||||
@@ -203,31 +148,6 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
|||||||
<DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />
|
<DetailRow label="آخر تحديث" value={fmt(role.updatedAt)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
{/* ── 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Button, EmptyState, Spinner } from "../../UI";
|
||||||
import type { ArchivedRole } from "@/src/types/role";
|
import type { ArchivedRole } from "@/src/types/role";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
// ── status badge ─────────────────────────────────────────────────────────────
|
||||||
|
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||||
|
// pill (no dot indicator), and the pulse-dot is the whole point of this chip.
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
function StatusBadge({ active }: { active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
|
||||||
@@ -14,6 +16,9 @@ function StatusBadge({ active }: { active: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── icon button ──────────────────────────────────────────────────────────────
|
// ── icon button ──────────────────────────────────────────────────────────────
|
||||||
|
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||||
|
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||||
|
// isn't built for a fixed 32×32 square icon chip.
|
||||||
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
|
||||||
@@ -68,11 +73,10 @@ export function ArchivedRoleTable({ roles, loading, search, page, pages, onView,
|
|||||||
</div>
|
</div>
|
||||||
) : roles.length === 0 ? (
|
) : roles.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="🛡️"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد أدوار في الأرشيف."}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* data rows */
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
@@ -114,15 +118,24 @@ export function ArchivedRoleTable({ roles, loading, search, page, pages, onView,
|
|||||||
صفحة <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>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
<Button
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
type="button"
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
variant="secondary"
|
||||||
].map(btn => (
|
size="sm"
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
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)" }}>
|
disabled={page === 1}
|
||||||
{btn.label}
|
>
|
||||||
</button>
|
السابق
|
||||||
))}
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user