update page to using same tamplet in all project not using inline code
This commit is contained in:
@@ -2,27 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import { Alert, Spinner } 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, FormErrors } from "@/src/types/branch";
|
||||||
|
|
||||||
// ── fixed styles ─────────────────────────────────────────
|
const FORM_ID = "branch-form";
|
||||||
const S = {
|
|
||||||
input: {
|
|
||||||
width: "100%", height: 40, padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, color: "var(--color-text-primary)",
|
|
||||||
outline: "none", fontFamily: "var(--font-sans)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
label: {
|
|
||||||
display: "flex", flexDirection: "column" as const,
|
|
||||||
gap: 6, fontSize: 12, fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── yup validation ────────────────────────────────────────────────────────────
|
// ── yup validation ────────────────────────────────────────────────────────────
|
||||||
async function validate(data: BranchFormData, isNew: boolean): Promise<FormErrors> {
|
async function validate(data: BranchFormData, isNew: boolean): Promise<FormErrors> {
|
||||||
@@ -74,11 +58,6 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => { firstInputRef.current?.focus(); }, []);
|
useEffect(() => { firstInputRef.current?.focus(); }, []);
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const set = (field: keyof BranchFormData) =>
|
const set = (field: keyof BranchFormData) =>
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -99,163 +78,168 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||||
};
|
};
|
||||||
|
|
||||||
// dynamic styles for inputs with errors
|
|
||||||
const inputStyle = (field: keyof FormErrors): React.CSSProperties => ({
|
|
||||||
...S.input,
|
|
||||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="branch-modal-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={isNew ? "فرع جديد" : editBranch?.name ?? ""}
|
||||||
style={{
|
subtitle={isNew ? "إضافة فرع" : "تعديل فرع"}
|
||||||
position: "fixed", inset: 0, zIndex: 50,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
size="md"
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
footer={
|
||||||
}}
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
||||||
|
إلغاء
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={FORM_ID} loading={saving}>
|
||||||
|
{isNew ? "إضافة الفرع" : "حفظ التغييرات"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<form
|
||||||
onClick={e => e.stopPropagation()}
|
id={FORM_ID}
|
||||||
style={{
|
onSubmit={handleSubmit}
|
||||||
width: "100%", maxWidth: 560,
|
noValidate
|
||||||
background: "var(--color-surface)",
|
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden",
|
|
||||||
display: "flex", flexDirection: "column",
|
|
||||||
maxHeight: "90vh",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* header */}
|
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
||||||
<div style={{
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
{/* name */}
|
||||||
padding: "1.25rem 1.5rem",
|
<Input
|
||||||
borderBottom: "1px solid var(--color-border)",
|
ref={firstInputRef}
|
||||||
background: "var(--color-surface-muted)",
|
label="اسم الفرع *"
|
||||||
}}>
|
value={form.name}
|
||||||
<div>
|
onChange={set("name")}
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
error={errors.name}
|
||||||
{isNew ? "إضافة فرع" : "تعديل فرع"}
|
placeholder="فرع الرياض"
|
||||||
</p>
|
autoComplete="organization"
|
||||||
<h2 id="branch-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
dir="rtl"
|
||||||
{isNew ? "فرع جديد" : editBranch?.name}
|
/>
|
||||||
</h2>
|
|
||||||
</div>
|
{/* email + phone */}
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
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" }}>
|
<Input
|
||||||
×
|
label="البريد الإلكتروني"
|
||||||
</button>
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={set("email")}
|
||||||
|
error={errors.email}
|
||||||
|
placeholder="branch@co.sa"
|
||||||
|
autoComplete="email"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="رقم الهاتف"
|
||||||
|
type="tel"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={set("phone")}
|
||||||
|
error={errors.phone}
|
||||||
|
placeholder="+966 5x xxx xxxx"
|
||||||
|
autoComplete="tel"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* city + street */}
|
||||||
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
<Input
|
||||||
|
label="المدينة *"
|
||||||
|
value={form.city}
|
||||||
|
onChange={set("city")}
|
||||||
|
error={errors.city}
|
||||||
|
placeholder="الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="الشارع *"
|
||||||
|
value={form.street}
|
||||||
|
onChange={set("street")}
|
||||||
|
error={errors.street}
|
||||||
|
placeholder="شارع الملك فهد"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* name */}
|
{/* state + district */}
|
||||||
<label style={S.label}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
اسم الفرع *
|
<Input
|
||||||
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="فرع الرياض" autoComplete="organization" dir="rtl" />
|
label="المنطقة"
|
||||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
value={form.state}
|
||||||
</label>
|
onChange={set("state")}
|
||||||
|
error={errors.state}
|
||||||
|
placeholder="منطقة الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="الحي"
|
||||||
|
value={form.district}
|
||||||
|
onChange={set("district")}
|
||||||
|
error={errors.district}
|
||||||
|
placeholder="حي العليا"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* email + phone */}
|
{/* buildingNo + unitNo + zipCode */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
<label style={S.label}>
|
<Input
|
||||||
البريد الإلكتروني
|
label="رقم المبنى"
|
||||||
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="branch@co.sa" autoComplete="email" dir="ltr" />
|
value={form.buildingNo}
|
||||||
{errors.email && <span style={S.errorText}>{errors.email}</span>}
|
onChange={set("buildingNo")}
|
||||||
</label>
|
error={errors.buildingNo}
|
||||||
<label style={S.label}>
|
placeholder="1234"
|
||||||
رقم الهاتف
|
dir="ltr"
|
||||||
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" />
|
/>
|
||||||
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
|
<Input
|
||||||
</label>
|
label="رقم الوحدة"
|
||||||
</div>
|
value={form.unitNo}
|
||||||
|
onChange={set("unitNo")}
|
||||||
|
error={errors.unitNo}
|
||||||
|
placeholder="5"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="الرمز البريدي"
|
||||||
|
value={form.zipCode}
|
||||||
|
onChange={set("zipCode")}
|
||||||
|
error={errors.zipCode}
|
||||||
|
placeholder="12345"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* city + street */}
|
{/* country */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<Input
|
||||||
<label style={S.label}>
|
label="الدولة"
|
||||||
المدينة *
|
value={form.country}
|
||||||
<input style={inputStyle("city")} value={form.city} onChange={set("city")} placeholder="الرياض" dir="rtl" />
|
onChange={set("country")}
|
||||||
{errors.city && <span style={S.errorText}>{errors.city}</span>}
|
error={errors.country}
|
||||||
</label>
|
placeholder="SA"
|
||||||
<label style={S.label}>
|
dir="ltr"
|
||||||
الشارع *
|
/>
|
||||||
<input style={inputStyle("street")} value={form.street} onChange={set("street")} placeholder="شارع الملك فهد" dir="rtl" />
|
|
||||||
{errors.street && <span style={S.errorText}>{errors.street}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* state + district */}
|
{/* latitude + longitude */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<label style={S.label}>
|
<Input
|
||||||
المنطقة
|
label="خط العرض (اختياري)"
|
||||||
<input style={inputStyle("state")} value={form.state} onChange={set("state")} placeholder="منطقة الرياض" dir="rtl" />
|
value={form.latitude}
|
||||||
{errors.state && <span style={S.errorText}>{errors.state}</span>}
|
onChange={set("latitude")}
|
||||||
</label>
|
error={errors.latitude}
|
||||||
<label style={S.label}>
|
placeholder="24.7136"
|
||||||
الحي
|
dir="ltr"
|
||||||
<input style={inputStyle("district")} value={form.district} onChange={set("district")} placeholder="حي العليا" dir="rtl" />
|
inputMode="decimal"
|
||||||
{errors.district && <span style={S.errorText}>{errors.district}</span>}
|
/>
|
||||||
</label>
|
<Input
|
||||||
</div>
|
label="خط الطول (اختياري)"
|
||||||
|
value={form.longitude}
|
||||||
{/* buildingNo + unitNo + zipCode */}
|
onChange={set("longitude")}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
error={errors.longitude}
|
||||||
<label style={S.label}>
|
placeholder="46.6753"
|
||||||
رقم المبنى
|
dir="ltr"
|
||||||
<input style={inputStyle("buildingNo")} value={form.buildingNo} onChange={set("buildingNo")} placeholder="1234" dir="ltr" />
|
inputMode="decimal"
|
||||||
{errors.buildingNo && <span style={S.errorText}>{errors.buildingNo}</span>}
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label style={S.label}>
|
</form>
|
||||||
رقم الوحدة
|
</Modal>
|
||||||
<input style={inputStyle("unitNo")} value={form.unitNo} onChange={set("unitNo")} placeholder="5" dir="ltr" />
|
|
||||||
{errors.unitNo && <span style={S.errorText}>{errors.unitNo}</span>}
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
الرمز البريدي
|
|
||||||
<input style={inputStyle("zipCode")} value={form.zipCode} onChange={set("zipCode")} placeholder="12345" dir="ltr" />
|
|
||||||
{errors.zipCode && <span style={S.errorText}>{errors.zipCode}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* country */}
|
|
||||||
<label style={S.label}>
|
|
||||||
الدولة
|
|
||||||
<input style={inputStyle("country")} value={form.country} onChange={set("country")} placeholder="SA" dir="ltr" />
|
|
||||||
{errors.country && <span style={S.errorText}>{errors.country}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* latitude + longitude */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
خط العرض (اختياري)
|
|
||||||
<input style={inputStyle("latitude")} value={form.latitude} onChange={set("latitude")} placeholder="24.7136" dir="ltr" inputMode="decimal" />
|
|
||||||
{errors.latitude && <span style={S.errorText}>{errors.latitude}</span>}
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
خط الطول (اختياري)
|
|
||||||
<input style={inputStyle("longitude")} value={form.longitude} onChange={set("longitude")} placeholder="46.6753" dir="ltr" inputMode="decimal" />
|
|
||||||
{errors.longitude && <span style={S.errorText}>{errors.longitude}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* actions */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
|
||||||
<button type="button" onClick={onClose} disabled={saving}
|
|
||||||
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={saving}
|
|
||||||
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
|
||||||
{saving && <Spinner size="sm" className="text-white" />}
|
|
||||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة الفرع" : "حفظ التغييرات"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, Input, Modal } from "../UI";
|
||||||
import {
|
import {
|
||||||
createMaintenanceSchema,
|
createMaintenanceSchema,
|
||||||
updateMaintenanceSchema,
|
updateMaintenanceSchema,
|
||||||
@@ -14,35 +14,7 @@ import type {
|
|||||||
UpdateMaintenancePayload,
|
UpdateMaintenancePayload,
|
||||||
} from "@/src/types/carMaintanance";
|
} from "@/src/types/carMaintanance";
|
||||||
|
|
||||||
// ── Shared input style ────────────────────────────────────────────────────────
|
const FORM_ID = "maintenance-form";
|
||||||
|
|
||||||
const inputBase: React.CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorTextStyle: React.CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-danger)",
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 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
|
||||||
@@ -130,20 +102,10 @@ export function CarMaintenanceFormModal({
|
|||||||
const firstRef = useRef<HTMLInputElement>(null);
|
const firstRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const parseNum = (v: string): number | undefined =>
|
const parseNum = (v: string): number | undefined =>
|
||||||
v.trim() === "" ? undefined : Number(v);
|
v.trim() === "" ? undefined : Number(v);
|
||||||
|
|
||||||
const inputStyle = (field: keyof MaintenanceFormErrors): React.CSSProperties => ({
|
|
||||||
...inputBase,
|
|
||||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearFieldError = (field: keyof MaintenanceFormErrors) =>
|
const clearFieldError = (field: keyof MaintenanceFormErrors) =>
|
||||||
setErrors((p) => ({ ...p, [field]: undefined }));
|
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||||
|
|
||||||
@@ -189,135 +151,72 @@ export function CarMaintenanceFormModal({
|
|||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
aria-labelledby="maintenance-modal-title"
|
size="sm"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
subtitle={isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"}
|
||||||
style={{
|
title={carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
|
||||||
position: "fixed", inset: 0, zIndex: 50,
|
footer={
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
<>
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
<Button variant="secondary" type="button" onClick={onClose} disabled={saving}>
|
||||||
padding: "1rem", overflowY: "auto",
|
إلغاء
|
||||||
}}
|
</Button>
|
||||||
|
<Button type="submit" form={FORM_ID} loading={saving}>
|
||||||
|
{isNew ? "إضافة السجل" : "حفظ التغييرات"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<form
|
||||||
onClick={(e) => e.stopPropagation()}
|
id={FORM_ID}
|
||||||
style={{
|
onSubmit={handleSubmit}
|
||||||
width: "100%", maxWidth: 520,
|
noValidate
|
||||||
background: "var(--color-surface)",
|
dir="rtl"
|
||||||
borderRadius: "var(--radius-2xl)",
|
className="flex flex-col gap-4"
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden",
|
|
||||||
margin: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
||||||
<div style={{
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
<Input
|
||||||
padding: "1.25rem 1.5rem",
|
ref={firstRef}
|
||||||
borderBottom: "1px solid var(--color-border)",
|
label="سبب الصيانة *"
|
||||||
background: "var(--color-surface-muted)",
|
value={reason}
|
||||||
}}>
|
onChange={(e) => { setReason(e.target.value); clearFieldError("reason"); }}
|
||||||
<div>
|
placeholder="تغيير زيت المحرك"
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
dir="rtl"
|
||||||
{isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"}
|
error={errors.reason}
|
||||||
</p>
|
/>
|
||||||
<h2 id="maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
{carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
</h2>
|
<Input
|
||||||
</div>
|
label="التكلفة (ر.س) *"
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
type="number"
|
||||||
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" }}>
|
min={0}
|
||||||
×
|
value={cost ?? ""}
|
||||||
</button>
|
onChange={(e) => { setCost(parseNum(e.target.value)); clearFieldError("cost"); }}
|
||||||
|
placeholder="0"
|
||||||
|
dir="ltr"
|
||||||
|
error={errors.cost}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="تاريخ البدء *"
|
||||||
|
type="date"
|
||||||
|
value={startAt}
|
||||||
|
onChange={(e) => { setStartAt(e.target.value); clearFieldError("startAt"); }}
|
||||||
|
error={errors.startAt}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
<Input
|
||||||
<form
|
label="تاريخ الانتهاء"
|
||||||
onSubmit={handleSubmit}
|
type="date"
|
||||||
noValidate
|
value={endAt}
|
||||||
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }}
|
onChange={(e) => { setEndAt(e.target.value); clearFieldError("endAt"); }}
|
||||||
dir="rtl"
|
error={errors.endAt}
|
||||||
>
|
hint="اتركه فارغاً إذا كانت الصيانة ما زالت جارية."
|
||||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
/>
|
||||||
|
</form>
|
||||||
<label style={labelStyle}>
|
</Modal>
|
||||||
سبب الصيانة *
|
|
||||||
<input
|
|
||||||
ref={firstRef}
|
|
||||||
style={inputStyle("reason")}
|
|
||||||
value={reason}
|
|
||||||
onChange={(e) => { setReason(e.target.value); clearFieldError("reason"); }}
|
|
||||||
placeholder="تغيير زيت المحرك"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.reason && <span style={errorTextStyle}>{errors.reason}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
التكلفة (ر.س) *
|
|
||||||
<input
|
|
||||||
style={inputStyle("cost")}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={cost ?? ""}
|
|
||||||
onChange={(e) => { setCost(parseNum(e.target.value)); clearFieldError("cost"); }}
|
|
||||||
placeholder="0"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.cost && <span style={errorTextStyle}>{errors.cost}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
|
||||||
تاريخ البدء *
|
|
||||||
<input
|
|
||||||
style={inputStyle("startAt")}
|
|
||||||
type="date"
|
|
||||||
value={startAt}
|
|
||||||
onChange={(e) => { setStartAt(e.target.value); clearFieldError("startAt"); }}
|
|
||||||
/>
|
|
||||||
{errors.startAt && <span style={errorTextStyle}>{errors.startAt}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
|
||||||
تاريخ الانتهاء
|
|
||||||
<input
|
|
||||||
style={inputStyle("endAt")}
|
|
||||||
type="date"
|
|
||||||
value={endAt}
|
|
||||||
onChange={(e) => { setEndAt(e.target.value); clearFieldError("endAt"); }}
|
|
||||||
/>
|
|
||||||
{errors.endAt && <span style={errorTextStyle}>{errors.endAt}</span>}
|
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-hint)", fontWeight: 400 }}>
|
|
||||||
اتركه فارغاً إذا كانت الصيانة ما زالت جارية.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={saving}
|
|
||||||
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}
|
|
||||||
>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}
|
|
||||||
>
|
|
||||||
{saving && <Spinner size="sm" className="text-white" />}
|
|
||||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة السجل" : "حفظ التغييرات"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, Input, Modal, Select } from "../UI";
|
||||||
import {
|
import {
|
||||||
createClientSchema,
|
createClientSchema,
|
||||||
updateClientSchema,
|
updateClientSchema,
|
||||||
@@ -11,39 +10,7 @@ import {
|
|||||||
} from "@/src/validations/client.validator";
|
} from "@/src/validations/client.validator";
|
||||||
import type { Client, ClientFormData } from "@/src/types/client";
|
import type { Client, ClientFormData } from "@/src/types/client";
|
||||||
|
|
||||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
const FORM_ID = "client-form";
|
||||||
const S = {
|
|
||||||
input: {
|
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
label: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column" as const,
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
errorText: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-danger)",
|
|
||||||
fontWeight: 500,
|
|
||||||
} as React.CSSProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
|
||||||
...S.input,
|
|
||||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────────────────────────
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
interface ClientFormModalProps {
|
interface ClientFormModalProps {
|
||||||
@@ -59,7 +26,6 @@ interface ClientFormModalProps {
|
|||||||
export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) {
|
export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) {
|
||||||
const isNew = editClient === null;
|
const isNew = editClient === null;
|
||||||
|
|
||||||
// FIX 1: was `Schema` (capital S) — now correctly `schema`
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -76,12 +42,6 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const submitHandler = async (data: ClientFormData) => {
|
const submitHandler = async (data: ClientFormData) => {
|
||||||
const ok = await onSubmit(data, isNew);
|
const ok = await onSubmit(data, isNew);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -92,255 +52,91 @@ export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormMod
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
aria-labelledby="client-modal-title"
|
size="sm"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
subtitle={isNew ? "إضافة عميل" : "تعديل عميل"}
|
||||||
style={{
|
title={isNew ? "عميل جديد" : editClient?.name ?? ""}
|
||||||
position: "fixed",
|
footer={
|
||||||
inset: 0,
|
<>
|
||||||
zIndex: 50,
|
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
|
||||||
background: "rgba(15,23,42,0.55)",
|
إلغاء
|
||||||
backdropFilter: "blur(4px)",
|
</Button>
|
||||||
display: "flex",
|
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||||
alignItems: "center",
|
{isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
||||||
justifyContent: "center",
|
</Button>
|
||||||
padding: "1rem",
|
</>
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<form
|
||||||
onClick={(e) => e.stopPropagation()}
|
id={FORM_ID}
|
||||||
style={{
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
width: "100%",
|
noValidate
|
||||||
maxWidth: 520,
|
dir="rtl"
|
||||||
background: "var(--color-surface)",
|
className="flex flex-col gap-4"
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{errors.name?.type === "manual" && (
|
||||||
<div
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||||
style={{
|
)}
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
<Input
|
||||||
justifyContent: "space-between",
|
label={`اسم العميل ${isNew ? "*" : ""}`}
|
||||||
padding: "1.25rem 1.5rem",
|
placeholder="شركة لوجي فلو للتوصيل"
|
||||||
borderBottom: "1px solid var(--color-border)",
|
autoComplete="organization"
|
||||||
background: "var(--color-surface-muted)",
|
dir="rtl"
|
||||||
}}
|
error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
|
||||||
>
|
{...register("name")}
|
||||||
<div>
|
/>
|
||||||
<p
|
|
||||||
style={{
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
fontSize: 11,
|
<Input
|
||||||
letterSpacing: "0.3em",
|
label="البريد الإلكتروني"
|
||||||
textTransform: "uppercase",
|
type="email"
|
||||||
color: "#2563EB",
|
placeholder="info@company.sa"
|
||||||
fontWeight: 600,
|
autoComplete="email"
|
||||||
margin: 0,
|
dir="ltr"
|
||||||
}}
|
error={errors.email?.message}
|
||||||
>
|
{...register("email")}
|
||||||
{isNew ? "إضافة عميل" : "تعديل عميل"}
|
/>
|
||||||
</p>
|
|
||||||
<h2
|
<Input
|
||||||
id="client-modal-title"
|
label={`رقم الهاتف ${isNew ? "*" : ""}`}
|
||||||
style={{
|
type="tel"
|
||||||
fontSize: 17,
|
placeholder="05xxxxxxxx"
|
||||||
fontWeight: 700,
|
autoComplete="tel"
|
||||||
color: "var(--color-text-primary)",
|
dir="ltr"
|
||||||
margin: "4px 0 0",
|
error={errors.phone?.message}
|
||||||
}}
|
{...register("phone")}
|
||||||
>
|
/>
|
||||||
{isNew ? "عميل جديد" : editClient?.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>
|
||||||
|
|
||||||
{/* Body */}
|
<Select
|
||||||
<form
|
label="نوع العميل"
|
||||||
onSubmit={handleSubmit(submitHandler)}
|
dir="rtl"
|
||||||
noValidate
|
error={errors.clientType?.message}
|
||||||
style={{
|
{...register("clientType")}
|
||||||
padding: "1.5rem",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{errors.name?.type === "manual" && (
|
<option value="">اختر النوع</option>
|
||||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
{CLIENT_TYPES.map((t) => (
|
||||||
)}
|
<option key={t} value={t}>
|
||||||
|
{t === "Individual" ? "فرد" : "شركة"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
<label style={S.label}>
|
{!isNew && (
|
||||||
اسم العميل {isNew && "*"}
|
<label className="flex items-center gap-2 text-[12px] font-semibold text-[var(--color-text-secondary)] cursor-pointer">
|
||||||
<input
|
<input
|
||||||
{...register("name")}
|
type="checkbox"
|
||||||
style={withError(!!errors.name)}
|
{...register("isActive" as never)}
|
||||||
placeholder="شركة لوجي فلو للتوصيل"
|
defaultChecked={editClient?.isActive ?? true}
|
||||||
autoComplete="organization"
|
className="w-3.5 h-3.5 cursor-pointer"
|
||||||
dir="rtl"
|
|
||||||
/>
|
/>
|
||||||
{errors.name && errors.name.type !== "manual" && (
|
عميل نشط
|
||||||
<span style={S.errorText}>{errors.name.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
<div
|
</form>
|
||||||
style={{
|
</Modal>
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gap: "0.75rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label style={S.label}>
|
|
||||||
البريد الإلكتروني
|
|
||||||
<input
|
|
||||||
{...register("email")}
|
|
||||||
style={withError(!!errors.email)}
|
|
||||||
type="email"
|
|
||||||
placeholder="info@company.sa"
|
|
||||||
autoComplete="email"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<span style={S.errorText}>{errors.email.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={S.label}>
|
|
||||||
رقم الهاتف {isNew && "*"}
|
|
||||||
<input
|
|
||||||
{...register("phone")}
|
|
||||||
style={withError(!!errors.phone)}
|
|
||||||
type="tel"
|
|
||||||
placeholder="05xxxxxxxx"
|
|
||||||
autoComplete="tel"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.phone && (
|
|
||||||
<span style={S.errorText}>{errors.phone.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={S.label}>
|
|
||||||
نوع العميل
|
|
||||||
<select
|
|
||||||
{...register("clientType")}
|
|
||||||
style={{ ...withError(!!errors.clientType), cursor: "pointer" }}
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
<option value="">اختر النوع</option>
|
|
||||||
{CLIENT_TYPES.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{t === "Individual" ? "فرد" : "شركة"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.clientType && (
|
|
||||||
<span style={S.errorText}>{errors.clientType.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{!isNew && (
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
{...register("isActive" as never)}
|
|
||||||
defaultChecked={editClient?.isActive ?? true}
|
|
||||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
|
||||||
/>
|
|
||||||
عميل نشط
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.5rem",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
paddingTop: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.5rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
background: isSubmitting
|
|
||||||
? "var(--color-brand-400)"
|
|
||||||
: "var(--color-brand-600)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Button } from "../../UI";
|
||||||
import { ArchivedClientTable } from "./ArchivedClientTable";
|
import { ArchivedClientTable } from "./ArchivedClientTable";
|
||||||
import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal";
|
import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal";
|
||||||
import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients";
|
import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients";
|
||||||
@@ -63,36 +63,33 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<Button
|
||||||
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
{/* search */}
|
{/* search */}
|
||||||
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<Input
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
label="بحث"
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="بحث بالاسم أو البريد الإلكتروني..."
|
placeholder="بحث بالاسم أو البريد الإلكتروني..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
icon={
|
||||||
width: "100%", height: 40,
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
paddingRight: 36, paddingLeft: 12,
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
borderRadius: "var(--radius-lg)",
|
</svg>
|
||||||
border: "1px solid var(--color-border)",
|
}
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm, type Resolver } from "react-hook-form";
|
import { useForm, type Resolver } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, Input, Modal } from "../UI";
|
||||||
import {
|
import {
|
||||||
createAddressSchema,
|
createAddressSchema,
|
||||||
updateAddressSchema,
|
updateAddressSchema,
|
||||||
@@ -13,32 +12,9 @@ import {
|
|||||||
import type { ClientAddress } from "@/src/types/client_adresses";
|
import type { ClientAddress } from "@/src/types/client_adresses";
|
||||||
|
|
||||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||||
|
// Only section-title styling is left here — every input/label/error visual
|
||||||
|
// is now owned by the shared <Input /> component.
|
||||||
const S = {
|
const S = {
|
||||||
input: {
|
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
label: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column" as const,
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
errorText: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-danger)",
|
|
||||||
fontWeight: 500,
|
|
||||||
} as React.CSSProperties,
|
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@@ -50,11 +26,7 @@ const S = {
|
|||||||
} as React.CSSProperties,
|
} as React.CSSProperties,
|
||||||
};
|
};
|
||||||
|
|
||||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
const FORM_ID = "address-form";
|
||||||
...S.input,
|
|
||||||
border: hasError ? "1px solid var(--color-danger)" : "1px solid var(--color-border)",
|
|
||||||
background: hasError ? "#FEF2F2" : S.input.background,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────────────────────────
|
// ── Props ──────────────────────────────────────────────────────────────────
|
||||||
interface AddressFormModalProps {
|
interface AddressFormModalProps {
|
||||||
@@ -138,12 +110,6 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
|
|||||||
location: locationErr = {},
|
location: locationErr = {},
|
||||||
} = errors as AddressErrors;
|
} = errors as AddressErrors;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => {
|
const submitHandler = async (data: CreateAddressFormValues | UpdateAddressFormValues) => {
|
||||||
const ok = await onSubmit(data);
|
const ok = await onSubmit(data);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -154,260 +120,178 @@ export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressForm
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
title={isNew ? "عنوان جديد" : editAddress?.label ?? ""}
|
||||||
aria-labelledby="addr-modal-title"
|
subtitle={isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClose={onClose}
|
||||||
style={{
|
size="md"
|
||||||
position: "fixed",
|
footer={
|
||||||
inset: 0,
|
<>
|
||||||
zIndex: 50,
|
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
background: "rgba(15,23,42,0.55)",
|
إلغاء
|
||||||
backdropFilter: "blur(4px)",
|
</Button>
|
||||||
display: "flex",
|
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||||
alignItems: "center",
|
{isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
||||||
justifyContent: "center",
|
</Button>
|
||||||
padding: "1rem",
|
</>
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<form
|
||||||
onClick={(e) => e.stopPropagation()}
|
id={FORM_ID}
|
||||||
style={{
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
width: "100%",
|
noValidate
|
||||||
maxWidth: 560,
|
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
maxHeight: "90vh",
|
|
||||||
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 */}
|
{errors.label?.type === "manual" && (
|
||||||
<div
|
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
|
||||||
style={{
|
)}
|
||||||
flexShrink: 0,
|
|
||||||
display: "flex",
|
{/* Address Meta */}
|
||||||
alignItems: "center",
|
<p style={S.sectionTitle}>بيانات العنوان</p>
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "1.25rem 1.5rem",
|
<Input
|
||||||
borderBottom: "1px solid var(--color-border)",
|
label={`نوع العنوان${isNew ? " *" : ""}`}
|
||||||
background: "var(--color-surface-muted)",
|
{...register("label")}
|
||||||
}}
|
error={errors.label && errors.label.type !== "manual" ? errors.label.message : undefined}
|
||||||
>
|
placeholder="مثال: منزل / مكتب / شحن"
|
||||||
<div>
|
dir="rtl"
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
/>
|
||||||
{isNew ? "إضافة عنوان" : "تعديل عنوان"}
|
|
||||||
</p>
|
<Input
|
||||||
<h2
|
label="اسم الفرع (اختياري)"
|
||||||
id="addr-modal-title"
|
{...register("branchName")}
|
||||||
style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}
|
placeholder="فرع الرياض"
|
||||||
>
|
dir="rtl"
|
||||||
{isNew ? "عنوان جديد" : editAddress?.label}
|
/>
|
||||||
</h2>
|
|
||||||
</div>
|
{/* Address Details */}
|
||||||
<button
|
<p style={S.sectionTitle}>تفاصيل العنوان</p>
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
<Input
|
||||||
aria-label="إغلاق"
|
label={`الشارع / العنوان التفصيلي${isNew ? " *" : ""}`}
|
||||||
style={{
|
{...register("details.street")}
|
||||||
width: 34,
|
error={detailsErr.street?.message}
|
||||||
height: 34,
|
placeholder="شارع الملك فهد، مبنى 12"
|
||||||
borderRadius: "var(--radius-md)",
|
dir="rtl"
|
||||||
border: "1px solid var(--color-border)",
|
/>
|
||||||
background: "var(--color-surface)",
|
|
||||||
cursor: "pointer",
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
fontSize: 18,
|
<Input
|
||||||
color: "var(--color-text-muted)",
|
label={`المدينة${isNew ? " *" : ""}`}
|
||||||
display: "flex",
|
{...register("details.city")}
|
||||||
alignItems: "center",
|
error={detailsErr.city?.message}
|
||||||
justifyContent: "center",
|
placeholder="الرياض"
|
||||||
}}
|
dir="rtl"
|
||||||
>
|
/>
|
||||||
×
|
<Input
|
||||||
</button>
|
label="المنطقة"
|
||||||
|
{...register("details.state")}
|
||||||
|
error={detailsErr.state?.message}
|
||||||
|
placeholder="منطقة الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable body */}
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
<Input
|
||||||
<form
|
label="الحي (اختياري)"
|
||||||
onSubmit={handleSubmit(submitHandler)}
|
{...register("details.district")}
|
||||||
noValidate
|
placeholder="حي العليا"
|
||||||
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}
|
dir="rtl"
|
||||||
>
|
/>
|
||||||
{errors.label?.type === "manual" && (
|
<Input
|
||||||
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
|
label="رقم المبنى (اختياري)"
|
||||||
)}
|
{...register("details.buildingNo")}
|
||||||
|
placeholder="1234"
|
||||||
{/* Address Meta */}
|
dir="ltr"
|
||||||
<p style={S.sectionTitle}>بيانات العنوان</p>
|
/>
|
||||||
|
|
||||||
<label style={S.label}>
|
|
||||||
نوع العنوان
|
|
||||||
<input
|
|
||||||
{...register("label")}
|
|
||||||
style={withError(!!errors.label && errors.label.type !== "manual")}
|
|
||||||
placeholder="مثال: منزل / مكتب / شحن"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.label && errors.label.type !== "manual" && (
|
|
||||||
<span style={S.errorText}>{errors.label.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={S.label}>
|
|
||||||
اسم الفرع (اختياري)
|
|
||||||
<input {...register("branchName")} style={S.input} placeholder="فرع الرياض" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Address Details */}
|
|
||||||
<p style={S.sectionTitle}>تفاصيل العنوان</p>
|
|
||||||
|
|
||||||
<label style={S.label}>
|
|
||||||
الشارع / العنوان التفصيلي {isNew && "*"}
|
|
||||||
<input
|
|
||||||
{...register("details.street")}
|
|
||||||
style={withError(!!detailsErr.street)}
|
|
||||||
placeholder="شارع الملك فهد، مبنى 12"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{detailsErr.street && <span style={S.errorText}>{detailsErr.street.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
المدينة {isNew && "*"}
|
|
||||||
<input {...register("details.city")} style={withError(!!detailsErr.city)} placeholder="الرياض" dir="rtl" />
|
|
||||||
{detailsErr.city && <span style={S.errorText}>{detailsErr.city.message}</span>}
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
المنطقة
|
|
||||||
<input {...register("details.state")} style={withError(!!detailsErr.state)} placeholder="منطقة الرياض" dir="rtl" />
|
|
||||||
{detailsErr.state && <span style={S.errorText}>{detailsErr.state.message}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
الحي (اختياري)
|
|
||||||
<input {...register("details.district")} style={S.input} placeholder="حي العليا" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
رقم المبنى (اختياري)
|
|
||||||
<input {...register("details.buildingNo")} style={S.input} placeholder="1234" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
رقم الوحدة (اختياري)
|
|
||||||
<input {...register("details.unitNo")} style={S.input} placeholder="5678" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
الرقم الإضافي (اختياري)
|
|
||||||
<input {...register("details.additionalNo")} style={S.input} placeholder="0000" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
الرمز البريدي (اختياري)
|
|
||||||
<input {...register("details.zipCode")} style={withError(!!detailsErr.zipCode)} placeholder="11564" dir="ltr" />
|
|
||||||
{detailsErr.zipCode && <span style={S.errorText}>{detailsErr.zipCode.message}</span>}
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
الدولة
|
|
||||||
<input {...register("details.country")} style={withError(!!detailsErr.country)} placeholder="SA" dir="ltr" />
|
|
||||||
{detailsErr.country && <span style={S.errorText}>{detailsErr.country.message}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={S.label}>
|
|
||||||
الشقة / الطابق (اختياري)
|
|
||||||
<input {...register("details.apartment")} style={S.input} placeholder="الطابق الثالث" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Contact Person */}
|
|
||||||
<p style={S.sectionTitle}>جهة الاتصال (اختياري)</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
الاسم
|
|
||||||
<input {...register("contactPerson.name")} style={withError(!!contactErr.name)} placeholder="أحمد محمد" dir="rtl" />
|
|
||||||
{contactErr.name && <span style={S.errorText}>{contactErr.name.message}</span>}
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
رقم الهاتف
|
|
||||||
<input {...register("contactPerson.phone")} style={withError(!!contactErr.phone)} type="tel" placeholder="05xxxxxxxx" dir="ltr" />
|
|
||||||
{contactErr.phone && <span style={S.errorText}>{contactErr.phone.message}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Coordinates */}
|
|
||||||
<p style={S.sectionTitle}>الإحداثيات الجغرافية {isNew && "*"}</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={S.label}>
|
|
||||||
خط الطول (Longitude) {isNew && "*"}
|
|
||||||
<input {...register("location.coordinates.0" as never)} style={withError(!!locationErr.coordinates)} type="number" step="any" placeholder="46.6753" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={S.label}>
|
|
||||||
خط العرض (Latitude) {isNew && "*"}
|
|
||||||
<input {...register("location.coordinates.1" as never)} style={withError(!!locationErr.coordinates)} type="number" step="any" placeholder="24.7136" dir="ltr" />
|
|
||||||
{locationErr.coordinates && <span style={S.errorText}>{locationErr.coordinates.message}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.5rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العنوان" : "حفظ التغييرات"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Input
|
||||||
|
label="رقم الوحدة (اختياري)"
|
||||||
|
{...register("details.unitNo")}
|
||||||
|
placeholder="5678"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="الرقم الإضافي (اختياري)"
|
||||||
|
{...register("details.additionalNo")}
|
||||||
|
placeholder="0000"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Input
|
||||||
|
label="الرمز البريدي (اختياري)"
|
||||||
|
{...register("details.zipCode")}
|
||||||
|
error={detailsErr.zipCode?.message}
|
||||||
|
placeholder="11564"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="الدولة"
|
||||||
|
{...register("details.country")}
|
||||||
|
error={detailsErr.country?.message}
|
||||||
|
placeholder="SA"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="الشقة / الطابق (اختياري)"
|
||||||
|
{...register("details.apartment")}
|
||||||
|
placeholder="الطابق الثالث"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Contact Person */}
|
||||||
|
<p style={S.sectionTitle}>جهة الاتصال (اختياري)</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Input
|
||||||
|
label="الاسم"
|
||||||
|
{...register("contactPerson.name")}
|
||||||
|
error={contactErr.name?.message}
|
||||||
|
placeholder="أحمد محمد"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="رقم الهاتف"
|
||||||
|
{...register("contactPerson.phone")}
|
||||||
|
error={contactErr.phone?.message}
|
||||||
|
type="tel"
|
||||||
|
placeholder="05xxxxxxxx"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coordinates */}
|
||||||
|
<p style={S.sectionTitle}>الإحداثيات الجغرافية{isNew ? " *" : ""}</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Input
|
||||||
|
label={`خط الطول (Longitude)${isNew ? " *" : ""}`}
|
||||||
|
{...register("location.coordinates.0" as never)}
|
||||||
|
error={locationErr.coordinates?.message}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="46.6753"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={`خط العرض (Latitude)${isNew ? " *" : ""}`}
|
||||||
|
{...register("location.coordinates.1" as never)}
|
||||||
|
error={locationErr.coordinates?.message}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="24.7136"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Button } from "../../UI";
|
||||||
import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable";
|
import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable";
|
||||||
import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal";
|
import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal";
|
||||||
import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses";
|
import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses";
|
||||||
@@ -67,36 +67,33 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<Button
|
||||||
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
{/* search — client-side only: this endpoint has no server-side search/pagination */}
|
{/* search — client-side only: this endpoint has no server-side search/pagination */}
|
||||||
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<Input
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
label="بحث"
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="بحث بالنوع أو المدينة أو الفرع..."
|
placeholder="بحث بالنوع أو المدينة أو الفرع..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
icon={
|
||||||
width: "100%", height: 40,
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
paddingRight: 36, paddingLeft: 12,
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
borderRadius: "var(--radius-lg)",
|
</svg>
|
||||||
border: "1px solid var(--color-border)",
|
}
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, FileInput, Input, Modal, Select } from "../UI";
|
||||||
import { get } from "@/src/services/api";
|
import { get } from "@/src/services/api";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator";
|
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator";
|
||||||
@@ -16,37 +16,8 @@ import type {
|
|||||||
DriverStatus,
|
DriverStatus,
|
||||||
} from "@/src/types/driver";
|
} from "@/src/types/driver";
|
||||||
import type { Branch } from "@/src/types/branch";
|
import type { Branch } from "@/src/types/branch";
|
||||||
import { FileInput } from "@/src/Components/Driver/DriverFormModalHelper";
|
|
||||||
|
|
||||||
// ── Shared input style ───────────────────────────────────────────────────────
|
// ── Shared bits ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const inputBase: React.CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorTextStyle: React.CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-danger)",
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectionHeadingStyle: React.CSSProperties = {
|
const sectionHeadingStyle: React.CSSProperties = {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -57,12 +28,7 @@ const sectionHeadingStyle: React.CSSProperties = {
|
|||||||
margin: "0.5rem 0 0",
|
margin: "0.5rem 0 0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const optionalLabelStyle: React.CSSProperties = {
|
const FORM_ID = "driver-form";
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--color-text-hint)",
|
|
||||||
marginRight: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Date helper ──────────────────────────────────────────────────────────────
|
// ── Date helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -144,9 +110,12 @@ export function DriverFormModal({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
setError,
|
setError,
|
||||||
|
setFocus,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<DriverFormValues>({
|
} = useForm<DriverFormValues>({
|
||||||
resolver: yupResolver(isNew ? createDriverSchema : updateDriverSchema) as never,
|
// Cast the schema itself — the create/update schemas have structurally
|
||||||
|
// different required fields, so yup can't unify them into one type.
|
||||||
|
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: editDriver?.name ?? "",
|
name: editDriver?.name ?? "",
|
||||||
phone: editDriver?.phone ?? "",
|
phone: editDriver?.phone ?? "",
|
||||||
@@ -174,23 +143,12 @@ export function DriverFormModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
const firstRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
|
// register("name") already attaches its own ref — focusing via setFocus
|
||||||
|
// avoids the ref collision that a separate `ref={firstRef}` would cause.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
firstRef.current?.focus();
|
setFocus("name");
|
||||||
}, []);
|
}, [setFocus]);
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
|
||||||
...inputBase,
|
|
||||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Submit ────────────────────────────────────────────────────────────────
|
// ── Submit ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -240,377 +198,250 @@ export function DriverFormModal({
|
|||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
aria-labelledby="driver-modal-title"
|
size="lg"
|
||||||
onClick={(e) => {
|
subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
|
||||||
if (e.target === e.currentTarget) onClose();
|
title={isNew ? "سائق جديد" : editDriver?.name ?? ""}
|
||||||
}}
|
footer={
|
||||||
style={{
|
<>
|
||||||
position: "fixed", inset: 0, zIndex: 50,
|
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
إلغاء
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
</Button>
|
||||||
padding: "1rem", overflowY: "auto",
|
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||||
}}
|
{isNew ? "إضافة السائق" : "حفظ التغييرات"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<form
|
||||||
onClick={(e) => e.stopPropagation()}
|
id={FORM_ID}
|
||||||
style={{
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
width: "100%", maxWidth: 640,
|
noValidate
|
||||||
background: "var(--color-surface)",
|
dir="rtl"
|
||||||
borderRadius: "var(--radius-2xl)",
|
className="flex flex-col gap-4"
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden", margin: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{errors.name?.type === "manual" && (
|
||||||
<div style={{
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} />
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
)}
|
||||||
padding: "1.25rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
{/* ── Section: Personal Info ── */}
|
||||||
background: "var(--color-surface-muted)",
|
<p style={sectionHeadingStyle}>البيانات الشخصية</p>
|
||||||
}}>
|
|
||||||
<div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
<Input
|
||||||
{isNew ? "إضافة سائق" : "تعديل سائق"}
|
label="الاسم الكامل *"
|
||||||
</p>
|
placeholder="محمد عبدالله"
|
||||||
<h2 id="driver-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
dir="rtl"
|
||||||
{isNew ? "سائق جديد" : editDriver?.name}
|
autoComplete="off"
|
||||||
</h2>
|
error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
|
||||||
</div>
|
{...register("name")}
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
/>
|
||||||
style={{
|
|
||||||
width: 34, height: 34, borderRadius: "var(--radius-md)",
|
<Input
|
||||||
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
label="رقم الجوال *"
|
||||||
cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)",
|
placeholder="05XXXXXXXX"
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
dir="ltr"
|
||||||
}}>
|
error={errors.phone?.message}
|
||||||
×
|
{...register("phone")}
|
||||||
</button>
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="البريد الإلكتروني (اختياري)"
|
||||||
|
type="email"
|
||||||
|
placeholder="example@mail.com"
|
||||||
|
dir="ltr"
|
||||||
|
error={errors.email?.message}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="الجنسية (اختياري)"
|
||||||
|
placeholder="سعودي"
|
||||||
|
dir="rtl"
|
||||||
|
error={errors.nationality?.message}
|
||||||
|
{...register("nationality")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="sm:col-span-2"
|
||||||
|
label="العنوان (اختياري)"
|
||||||
|
placeholder="الرياض، حي..."
|
||||||
|
dir="rtl"
|
||||||
|
error={errors.address?.message}
|
||||||
|
{...register("address")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* ── Section: ID & GOSI ── */}
|
||||||
<form
|
<p style={sectionHeadingStyle}>الهوية والتأمينات</p>
|
||||||
onSubmit={handleSubmit(submitHandler)}
|
|
||||||
noValidate
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
style={{
|
<Select
|
||||||
padding: "1.5rem",
|
label="نوع الهوية (اختياري)"
|
||||||
display: "flex", flexDirection: "column", gap: "1rem",
|
dir="rtl"
|
||||||
maxHeight: "75vh", overflowY: "auto",
|
error={errors.nationalIdType?.message}
|
||||||
}}
|
{...register("nationalIdType")}
|
||||||
dir="rtl"
|
>
|
||||||
>
|
<option value="">اختر النوع</option>
|
||||||
{errors.name?.type === "manual" && (
|
<option value="NationalID">هوية وطنية</option>
|
||||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} />
|
<option value="Iqama">إقامة</option>
|
||||||
|
<option value="Passport">جواز سفر</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="رقم الهوية (اختياري)"
|
||||||
|
placeholder="1XXXXXXXXX"
|
||||||
|
dir="ltr"
|
||||||
|
error={errors.nationalId?.message}
|
||||||
|
{...register("nationalId")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="انتهاء الهوية (اختياري)"
|
||||||
|
type="date"
|
||||||
|
error={errors.nationalIdExpiry?.message}
|
||||||
|
{...register("nationalIdExpiry")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="رقم GOSI (اختياري)"
|
||||||
|
placeholder="GOSI-XXXX"
|
||||||
|
dir="ltr"
|
||||||
|
error={errors.gosiNumber?.message}
|
||||||
|
{...register("gosiNumber")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: License ── */}
|
||||||
|
<p style={sectionHeadingStyle}>رخصة القيادة</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Input
|
||||||
|
label="رقم الرخصة (اختياري)"
|
||||||
|
placeholder="LIC-XXXX"
|
||||||
|
dir="ltr"
|
||||||
|
error={errors.licenseNumber?.message}
|
||||||
|
{...register("licenseNumber")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="نوع الرخصة (اختياري)"
|
||||||
|
placeholder="خاص / عام"
|
||||||
|
dir="rtl"
|
||||||
|
error={errors.licenseType?.message}
|
||||||
|
{...register("licenseType")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="انتهاء الرخصة (اختياري)"
|
||||||
|
type="date"
|
||||||
|
error={errors.licenseExpiry?.message}
|
||||||
|
{...register("licenseExpiry")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: Driver Card ── */}
|
||||||
|
<p style={sectionHeadingStyle}>بطاقة السائق</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Input
|
||||||
|
label="رقم البطاقة (اختياري)"
|
||||||
|
placeholder="CARD-XXXX"
|
||||||
|
dir="ltr"
|
||||||
|
error={errors.driverCardNumber?.message}
|
||||||
|
{...register("driverCardNumber")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="نوع البطاقة (اختياري)"
|
||||||
|
dir="rtl"
|
||||||
|
error={errors.driverCardType?.message}
|
||||||
|
{...register("driverCardType")}
|
||||||
|
>
|
||||||
|
<option value="">اختر النوع</option>
|
||||||
|
<option value="Temporary">مؤقتة</option>
|
||||||
|
<option value="Seasonal">موسمية</option>
|
||||||
|
<option value="Annual">سنوية</option>
|
||||||
|
<option value="Restricted">مقيدة</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="انتهاء البطاقة (اختياري)"
|
||||||
|
type="date"
|
||||||
|
error={errors.driverCardExpiry?.message}
|
||||||
|
{...register("driverCardExpiry")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section: Operational ── */}
|
||||||
|
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Select
|
||||||
|
label="الفرع (اختياري)"
|
||||||
|
dir="rtl"
|
||||||
|
error={errors.branchId?.message}
|
||||||
|
{...register("branchId")}
|
||||||
|
>
|
||||||
|
<option value="">اختر الفرع</option>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>{b.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="نوع السائق (اختياري)"
|
||||||
|
placeholder="رئيسي / احتياطي"
|
||||||
|
dir="rtl"
|
||||||
|
error={errors.driverType?.message}
|
||||||
|
{...register("driverType")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status — edit only */}
|
||||||
|
{!isNew && (
|
||||||
|
<Select label="الحالة" dir="rtl" {...register("status")}>
|
||||||
|
<option value="Active">نشط</option>
|
||||||
|
<option value="InTrip">في رحلة</option>
|
||||||
|
<option value="Inactive">غير نشط</option>
|
||||||
|
<option value="Suspended">موقوف</option>
|
||||||
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Section: Personal Info ── */}
|
{/* ── Section: Photos ── */}
|
||||||
<p style={sectionHeadingStyle}>البيانات الشخصية</p>
|
<p style={sectionHeadingStyle}>الصور والمستندات</p>
|
||||||
|
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}>
|
||||||
|
جميع حقول الصور اختيارية
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
{/* Name — required */}
|
{/* File inputs cannot use register() directly — bind via Controller
|
||||||
<label style={labelStyle}>
|
to the existing FileInput component's current/onChange props. */}
|
||||||
الاسم الكامل *
|
<Controller
|
||||||
<input
|
name="photo"
|
||||||
ref={firstRef}
|
control={control}
|
||||||
style={withError(!!errors.name && errors.name.type !== "manual")}
|
render={({ field }) => (
|
||||||
{...register("name")}
|
<FileInput label="صورة السائق" current={field.value} onChange={field.onChange} />
|
||||||
placeholder="محمد عبدالله"
|
|
||||||
dir="rtl"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{errors.name && errors.name.type !== "manual" && (
|
|
||||||
<span style={errorTextStyle}>{errors.name.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Phone — required */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم الجوال *
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.phone)}
|
|
||||||
{...register("phone")}
|
|
||||||
placeholder="05XXXXXXXX"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.phone && <span style={errorTextStyle}>{errors.phone.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Email — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
البريد الإلكتروني
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.email)}
|
|
||||||
type="email"
|
|
||||||
{...register("email")}
|
|
||||||
placeholder="example@mail.com"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.email && <span style={errorTextStyle}>{errors.email.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Nationality — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الجنسية
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.nationality)}
|
|
||||||
{...register("nationality")}
|
|
||||||
placeholder="سعودي"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.nationality && <span style={errorTextStyle}>{errors.nationality.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Address — optional, full width */}
|
|
||||||
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
|
|
||||||
العنوان
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.address)}
|
|
||||||
{...register("address")}
|
|
||||||
placeholder="الرياض، حي..."
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.address && <span style={errorTextStyle}>{errors.address.message}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Section: ID & GOSI ── */}
|
|
||||||
<p style={sectionHeadingStyle}>الهوية والتأمينات</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
{/* National ID Type — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
نوع الهوية
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<select
|
|
||||||
style={{ ...withError(!!errors.nationalIdType), cursor: "pointer" }}
|
|
||||||
{...register("nationalIdType")}
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
<option value="">اختر النوع</option>
|
|
||||||
<option value="NationalID">هوية وطنية</option>
|
|
||||||
<option value="Iqama">إقامة</option>
|
|
||||||
<option value="Passport">جواز سفر</option>
|
|
||||||
</select>
|
|
||||||
{errors.nationalIdType && <span style={errorTextStyle}>{errors.nationalIdType.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* National ID number */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم الهوية
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("nationalId")} placeholder="1XXXXXXXXX" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* National ID expiry */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
انتهاء الهوية
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} type="date" {...register("nationalIdExpiry")} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* GOSI — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم GOSI
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("gosiNumber")} placeholder="GOSI-XXXX" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Section: License ── */}
|
|
||||||
<p style={sectionHeadingStyle}>رخصة القيادة</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
{/* License number — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم الرخصة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.licenseNumber)}
|
|
||||||
{...register("licenseNumber")}
|
|
||||||
placeholder="LIC-XXXX"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.licenseNumber && <span style={errorTextStyle}>{errors.licenseNumber.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* License type — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
نوع الرخصة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("licenseType")} placeholder="خاص / عام" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* License expiry — optional, validated if provided */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
انتهاء الرخصة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={withError(!!errors.licenseExpiry)} type="date" {...register("licenseExpiry")} />
|
|
||||||
{errors.licenseExpiry && <span style={errorTextStyle}>{errors.licenseExpiry.message}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Section: Driver Card ── */}
|
|
||||||
<p style={sectionHeadingStyle}>بطاقة السائق</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
{/* Card number — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم البطاقة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("driverCardNumber")} placeholder="CARD-XXXX" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Card type — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
نوع البطاقة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<select
|
|
||||||
style={{ ...withError(!!errors.driverCardType), cursor: "pointer" }}
|
|
||||||
{...register("driverCardType")}
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
<option value="">اختر النوع</option>
|
|
||||||
<option value="Temporary">مؤقتة</option>
|
|
||||||
<option value="Seasonal">موسمية</option>
|
|
||||||
<option value="Annual">سنوية</option>
|
|
||||||
<option value="Restricted">مقيدة</option>
|
|
||||||
</select>
|
|
||||||
{errors.driverCardType && <span style={errorTextStyle}>{errors.driverCardType.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Card expiry — optional, validated if provided */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
انتهاء البطاقة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.driverCardExpiry)}
|
|
||||||
type="date"
|
|
||||||
{...register("driverCardExpiry")}
|
|
||||||
/>
|
|
||||||
{errors.driverCardExpiry && (
|
|
||||||
<span style={errorTextStyle}>{errors.driverCardExpiry.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Section: Operational ── */}
|
|
||||||
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
{/* Branch — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الفرع
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<select
|
|
||||||
style={{ ...withError(!!errors.branchId), cursor: "pointer" }}
|
|
||||||
{...register("branchId")}
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
<option value="">اختر الفرع</option>
|
|
||||||
{branches.map((b) => (
|
|
||||||
<option key={b.id} value={b.id}>{b.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.branchId && <span style={errorTextStyle}>{errors.branchId.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Driver type — optional */}
|
|
||||||
<label style={labelStyle}>
|
|
||||||
نوع السائق
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("driverType")} placeholder="رئيسي / احتياطي" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Status — edit only */}
|
|
||||||
{!isNew && (
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الحالة
|
|
||||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("status")} dir="rtl">
|
|
||||||
<option value="Active">نشط</option>
|
|
||||||
<option value="InTrip">في رحلة</option>
|
|
||||||
<option value="Inactive">غير نشط</option>
|
|
||||||
<option value="Suspended">موقوف</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
|
<Controller
|
||||||
{/* ── Section: Photos ── */}
|
name="nationalPhoto"
|
||||||
<p style={sectionHeadingStyle}>الصور والمستندات</p>
|
control={control}
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}>
|
render={({ field }) => (
|
||||||
جميع حقول الصور اختيارية
|
<FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} />
|
||||||
</p>
|
)}
|
||||||
|
/>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
<Controller
|
||||||
{/* File inputs cannot use register() directly — bind via Controller
|
name="driverCardPhoto"
|
||||||
to the existing FileInput component's current/onChange props. */}
|
control={control}
|
||||||
<Controller
|
render={({ field }) => (
|
||||||
name="photo"
|
<FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} />
|
||||||
control={control}
|
)}
|
||||||
render={({ field }) => (
|
/>
|
||||||
<FileInput label="صورة السائق" current={field.value} onChange={field.onChange} />
|
</div>
|
||||||
)}
|
</form>
|
||||||
/>
|
</Modal>
|
||||||
<Controller
|
|
||||||
name="nationalPhoto"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="driverCardPhoto"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Actions ── */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40, padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40, padding: "0 1.5rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
|
||||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex", alignItems: "center", gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة السائق" : "حفظ التغييرات"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedDriverTable } from "./ArchivedDriverTable";
|
import { ArchivedDriverTable } from "./ArchivedDriverTable";
|
||||||
import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal";
|
import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal";
|
||||||
import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers";
|
import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers";
|
||||||
@@ -10,6 +10,21 @@ interface ArchivedDriversProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level modal for browsing archived drivers. Wires the
|
* Top-level modal for browsing archived drivers. Wires the
|
||||||
* useArchivedDrivers hook to the table and detail modal, mirroring
|
* useArchivedDrivers hook to the table and detail modal, mirroring
|
||||||
@@ -25,79 +40,28 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
|
|||||||
} = useArchivedDrivers();
|
} = useArchivedDrivers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archive-driver-modal-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewDriverId && (
|
{viewDriverId && (
|
||||||
<ArchivedDriverDetailModal driverId={viewDriverId} onClose={() => setViewDriverId(null)} />
|
<ArchivedDriverDetailModal driverId={viewDriverId} onClose={() => setViewDriverId(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<Modal
|
||||||
onClick={e => e.stopPropagation()}
|
open
|
||||||
style={{
|
onClose={onClose}
|
||||||
width: "100%", maxWidth: 920,
|
size="lg"
|
||||||
background: "var(--color-surface-sunken)",
|
subtitle="الأرشيف"
|
||||||
borderRadius: "var(--radius-xl)",
|
title={`السائقون المؤرشفون (${total})`}
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* header */}
|
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
<div dir="rtl" style={{
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
||||||
padding: "1.25rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
|
|
||||||
الأرشيف
|
|
||||||
</p>
|
|
||||||
<h2 id="archive-driver-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
السائقون المؤرشفون
|
|
||||||
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
|
|
||||||
({total})
|
|
||||||
</span>
|
|
||||||
</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", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
||||||
{/* search */}
|
{/* search */}
|
||||||
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<Input
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
label="بحث"
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="بحث بالاسم أو الهاتف..."
|
placeholder="بحث بالاسم أو الهاتف..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
icon={searchIcon}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
|
||||||
width: "100%", height: 40,
|
|
||||||
paddingRight: 36, paddingLeft: 12,
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,7 +77,7 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Spinner } from "../UI";
|
import { Alert, Button, Input } from "../UI";
|
||||||
import { driverService } from "@/src/services/driver.service";
|
import { driverService } from "@/src/services/driver.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
|
||||||
@@ -65,20 +65,8 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1, minWidth: 140 }}>
|
<div style={{ flex: 1, minWidth: 140 }}>
|
||||||
<label
|
<Input
|
||||||
htmlFor="report-date"
|
label="تاريخ التقرير"
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
تاريخ التقرير
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="report-date"
|
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
max={today}
|
max={today}
|
||||||
@@ -86,64 +74,21 @@ export function DriverReportPanel({ driverId }: DriverReportPanelProps) {
|
|||||||
setDate(e.target.value);
|
setDate(e.target.value);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: 38,
|
|
||||||
padding: "0 0.625rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button onClick={handleGenerate} disabled={!date} loading={loading}>
|
||||||
type="button"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={loading || !date}
|
|
||||||
style={{
|
|
||||||
height: 38,
|
|
||||||
padding: "0 1rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
background: !date || loading
|
|
||||||
? "var(--color-brand-400)"
|
|
||||||
: "var(--color-brand-600)",
|
|
||||||
cursor: !date || 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>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<Alert
|
||||||
style={{
|
type="error"
|
||||||
marginTop: "0.75rem",
|
message={error}
|
||||||
borderRadius: "var(--radius-md)",
|
onClose={() => setError(null)}
|
||||||
background: "#FEF2F2",
|
className="mt-3"
|
||||||
border: "1px solid #FECACA",
|
/>
|
||||||
padding: "0.625rem 0.875rem",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#DC2626",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⚠ {error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, Input, Modal, Select } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { clientService } from "@/src/services/client.service";
|
import { clientService } from "@/src/services/client.service";
|
||||||
import { tripService } from "@/src/services/trip.service";
|
import { tripService } from "@/src/services/trip.service";
|
||||||
@@ -22,34 +22,6 @@ import type {
|
|||||||
import type { Client } from "@/src/types/client";
|
import type { Client } from "@/src/types/client";
|
||||||
import type { Trip } from "@/src/types/trip";
|
import type { Trip } from "@/src/types/trip";
|
||||||
|
|
||||||
const inputBase: React.CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorTextStyle: React.CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-danger)",
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectionHeadingStyle: React.CSSProperties = {
|
const sectionHeadingStyle: React.CSSProperties = {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
letterSpacing: "0.25em",
|
letterSpacing: "0.25em",
|
||||||
@@ -59,19 +31,12 @@ const sectionHeadingStyle: React.CSSProperties = {
|
|||||||
margin: "0.5rem 0 0",
|
margin: "0.5rem 0 0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const optionalLabelStyle: React.CSSProperties = {
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--color-text-hint)",
|
|
||||||
marginRight: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const createLinkStyle: React.CSSProperties = {
|
const createLinkStyle: React.CSSProperties = {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: "var(--color-brand-600)",
|
color: "var(--color-brand-600)",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
marginTop: 2,
|
marginTop: 6,
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 3,
|
gap: 3,
|
||||||
@@ -147,9 +112,12 @@ export function OrderFormModal({
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
|
setFocus,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<OrderFormValues>({
|
} = useForm<OrderFormValues>({
|
||||||
resolver: yupResolver(isNew ? createOrderSchema : updateOrderSchema) as never,
|
// Cast the schema itself — create/update schemas differ structurally in
|
||||||
|
// which fields are required, so yup can't unify them into one type.
|
||||||
|
resolver: yupResolver<OrderFormValues>((isNew ? createOrderSchema : updateOrderSchema) as any),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
shipmentNumber: editOrder?.shipmentNumber ?? "",
|
shipmentNumber: editOrder?.shipmentNumber ?? "",
|
||||||
recipientName: editOrder?.recipientName ?? "",
|
recipientName: editOrder?.recipientName ?? "",
|
||||||
@@ -200,7 +168,12 @@ export function OrderFormModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
const firstRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
// register("shipmentNumber") already attaches its own ref — focusing via
|
||||||
|
// setFocus avoids the ref collision a separate `ref={firstRef}` would cause.
|
||||||
|
useEffect(() => {
|
||||||
|
setFocus("shipmentNumber");
|
||||||
|
}, [setFocus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -227,9 +200,6 @@ export function OrderFormModal({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
firstRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = (e: KeyboardEvent) => {
|
const h = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@@ -238,11 +208,6 @@ export function OrderFormModal({
|
|||||||
return () => window.removeEventListener("keydown", h);
|
return () => window.removeEventListener("keydown", h);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
|
||||||
...inputBase,
|
|
||||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const numberField = (
|
const numberField = (
|
||||||
field:
|
field:
|
||||||
| "quantity"
|
| "quantity"
|
||||||
@@ -340,438 +305,371 @@ export function OrderFormModal({
|
|||||||
const pickupErr = errors.pickupAddress;
|
const pickupErr = errors.pickupAddress;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
title={isNew ? "طلب جديد" : editOrder?.shipmentNumber ?? ""}
|
||||||
aria-labelledby="order-modal-title"
|
subtitle={isNew ? "إنشاء طلب" : "تعديل طلب"}
|
||||||
onClick={(e) => {
|
onClose={onClose}
|
||||||
if (e.target === e.currentTarget) onClose();
|
size="lg"
|
||||||
}}
|
footer={
|
||||||
style={{
|
<>
|
||||||
position: "fixed",
|
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
inset: 0,
|
إلغاء
|
||||||
zIndex: 50,
|
</Button>
|
||||||
background: "rgba(15,23,42,0.55)",
|
<Button type="submit" form="order-form" loading={isSubmitting}>
|
||||||
backdropFilter: "blur(4px)",
|
{isNew ? "إنشاء الطلب" : "حفظ التغييرات"}
|
||||||
display: "flex",
|
</Button>
|
||||||
alignItems: "center",
|
</>
|
||||||
justifyContent: "center",
|
}
|
||||||
padding: "1rem",
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<form
|
||||||
onClick={(e) => e.stopPropagation()}
|
id="order-form"
|
||||||
style={{
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
width: "100%",
|
noValidate
|
||||||
maxWidth: 640,
|
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
background: "var(--color-surface)",
|
dir="rtl"
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden",
|
|
||||||
margin: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
{errors.shipmentNumber?.type === "manual" && (
|
||||||
style={{
|
<Alert type="error" message={errors.shipmentNumber.message ?? ""} onClose={() => setApiError("")} />
|
||||||
display: "flex",
|
)}
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
||||||
padding: "1.25rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
background: "var(--color-surface-muted)",
|
<Input
|
||||||
}}
|
id="order-shipmentNumber"
|
||||||
>
|
label="رقم الشحنة *"
|
||||||
|
error={
|
||||||
|
errors.shipmentNumber && errors.shipmentNumber.type !== "manual"
|
||||||
|
? errors.shipmentNumber.message
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
{...register("shipmentNumber")}
|
||||||
|
placeholder="SHP-0001"
|
||||||
|
dir="ltr"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
<Select
|
||||||
{isNew ? "إنشاء طلب" : "تعديل طلب"}
|
id="order-clientId"
|
||||||
</p>
|
label="العميل *"
|
||||||
<h2
|
error={errors.clientId?.message}
|
||||||
id="order-modal-title"
|
disabled={relLoading}
|
||||||
style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0", fontFamily: "var(--font-mono)" }}
|
{...register("clientId")}
|
||||||
|
dir="rtl"
|
||||||
|
style={{ cursor: relLoading ? "wait" : "pointer" }}
|
||||||
>
|
>
|
||||||
{isNew ? "طلب جديد" : editOrder?.shipmentNumber}
|
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر العميل"}</option>
|
||||||
</h2>
|
{clients.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
{c.phone ? ` — ${c.phone}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
إنشاء عميل جديد
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="order-recipientName"
|
||||||
|
label="اسم المستلم *"
|
||||||
|
error={errors.recipientName?.message}
|
||||||
|
{...register("recipientName")}
|
||||||
|
placeholder="أحمد محمد"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="order-recipientPhone"
|
||||||
|
label="رقم جوال المستلم *"
|
||||||
|
error={errors.recipientPhone?.message}
|
||||||
|
{...register("recipientPhone")}
|
||||||
|
placeholder="05XXXXXXXX"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ gridColumn: "1 / -1" }}>
|
||||||
|
<Select
|
||||||
|
id="order-tripId"
|
||||||
|
label={isNew ? "الرحلة *" : "الرحلة (اختياري)"}
|
||||||
|
error={errors.tripId?.message}
|
||||||
|
disabled={relLoading}
|
||||||
|
{...register("tripId")}
|
||||||
|
dir="rtl"
|
||||||
|
style={{ cursor: relLoading ? "wait" : "pointer" }}
|
||||||
|
>
|
||||||
|
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر الرحلة"}</option>
|
||||||
|
{trips.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.tripNumber}
|
||||||
|
{t.title ? ` — ${t.title}` : ""}
|
||||||
|
{t.driver?.name ? ` (${t.driver.name})` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
إنشاء رحلة جديدة
|
||||||
|
</Link>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<form
|
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
|
||||||
onSubmit={handleSubmit(submitHandler)}
|
|
||||||
noValidate
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }}
|
<Input
|
||||||
dir="rtl"
|
id="order-type"
|
||||||
>
|
label="نوع الشحنة (اختياري)"
|
||||||
{errors.shipmentNumber?.type === "manual" && (
|
{...register("type")}
|
||||||
<Alert type="error" message={errors.shipmentNumber.message ?? ""} onClose={() => setApiError("")} />
|
placeholder="عادي / مبرد"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="order-quantity"
|
||||||
|
label="الكمية *"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
error={errors.quantity?.message}
|
||||||
|
{...numberField("quantity")}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="order-weight"
|
||||||
|
label="الوزن (كجم) (اختياري)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
{...numberField("weight")}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={sectionHeadingStyle}>بيانات الدفع</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Input
|
||||||
|
id="order-subTotal"
|
||||||
|
label="الإجمالي الفرعي (اختياري)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
error={errors.subTotal?.message}
|
||||||
|
{...numberField("subTotal")}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="order-vatRate"
|
||||||
|
label="نسبة الضريبة (%) (اختياري)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
error={errors.vatRate?.message}
|
||||||
|
{...numberField("vatRate")}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
id="order-paymentMethod"
|
||||||
|
label="طريقة الدفع (اختياري)"
|
||||||
|
{...register("paymentMethod")}
|
||||||
|
dir="rtl"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<option value="">اختر الطريقة</option>
|
||||||
|
<option value="Cash">نقداً</option>
|
||||||
|
<option value="Card">بطاقة</option>
|
||||||
|
<option value="Prepaid">مدفوع مسبقاً</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<Select
|
||||||
|
id="order-paymentStatus"
|
||||||
|
label="حالة الدفع"
|
||||||
|
{...register("paymentStatus")}
|
||||||
|
dir="rtl"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="Pending">معلَّق</option>
|
||||||
|
<option value="Paid">مدفوع</option>
|
||||||
|
<option value="Failed">فشل</option>
|
||||||
|
<option value="Refunded">مُسترجع</option>
|
||||||
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
<p style={sectionHeadingStyle}>عنوان التسليم</p>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Input
|
||||||
|
id="delivery-city"
|
||||||
|
label="المدينة (اختياري)"
|
||||||
|
{...register("deliveryAddress.details.city")}
|
||||||
|
placeholder="الرياض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="delivery-district"
|
||||||
|
label="الحي (اختياري)"
|
||||||
|
{...register("deliveryAddress.details.district")}
|
||||||
|
placeholder="العليا"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="delivery-street"
|
||||||
|
label="الشارع (اختياري)"
|
||||||
|
{...register("deliveryAddress.details.street")}
|
||||||
|
placeholder="شارع الأمير محمد"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="delivery-buildingNo"
|
||||||
|
label="رقم المبنى (اختياري)"
|
||||||
|
{...register("deliveryAddress.details.buildingNo")}
|
||||||
|
placeholder="1234"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="delivery-unitNo"
|
||||||
|
label="رقم الوحدة (اختياري)"
|
||||||
|
{...register("deliveryAddress.details.unitNo")}
|
||||||
|
placeholder="56"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="delivery-zipCode"
|
||||||
|
label="الرمز البريدي (اختياري)"
|
||||||
|
{...register("deliveryAddress.details.zipCode")}
|
||||||
|
placeholder="12345"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="delivery-lng"
|
||||||
|
label="خط الطول (Longitude) *"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
error={deliveryErr?.location?.coordinates ? " " : undefined}
|
||||||
|
{...coordField("deliveryAddress.location.coordinates.0")}
|
||||||
|
placeholder="46.6753"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="delivery-lat"
|
||||||
|
label="خط العرض (Latitude) *"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
error={deliveryErr?.location?.coordinates?.message}
|
||||||
|
{...coordField("deliveryAddress.location.coordinates.1")}
|
||||||
|
placeholder="24.7136"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم الشحنة *
|
|
||||||
<input
|
|
||||||
ref={firstRef}
|
|
||||||
style={withError(!!errors.shipmentNumber && errors.shipmentNumber.type !== "manual")}
|
|
||||||
{...register("shipmentNumber")}
|
|
||||||
placeholder="SHP-0001"
|
|
||||||
dir="ltr"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{errors.shipmentNumber && errors.shipmentNumber.type !== "manual" && (
|
|
||||||
<span style={errorTextStyle}>{errors.shipmentNumber.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}>
|
||||||
العميل *
|
{(["id", "new"] as const).map((m) => (
|
||||||
<select
|
<Button
|
||||||
style={{ ...withError(!!errors.clientId), cursor: relLoading ? "wait" : "pointer" }}
|
key={m}
|
||||||
disabled={relLoading}
|
|
||||||
{...register("clientId")}
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر العميل"}</option>
|
|
||||||
{clients.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
{c.phone ? ` — ${c.phone}` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.clientId && <span style={errorTextStyle}>{errors.clientId.message}</span>}
|
|
||||||
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
إنشاء عميل جديد
|
|
||||||
</Link>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
|
||||||
اسم المستلم *
|
|
||||||
<input style={withError(!!errors.recipientName)} {...register("recipientName")} placeholder="أحمد محمد" dir="rtl" />
|
|
||||||
{errors.recipientName && <span style={errorTextStyle}>{errors.recipientName.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم جوال المستلم *
|
|
||||||
<input style={withError(!!errors.recipientPhone)} {...register("recipientPhone")} placeholder="05XXXXXXXX" dir="ltr" />
|
|
||||||
{errors.recipientPhone && <span style={errorTextStyle}>{errors.recipientPhone.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
|
|
||||||
{isNew ? "الرحلة *" : "الرحلة"}
|
|
||||||
{!isNew && <span style={optionalLabelStyle}>(اختياري)</span>}
|
|
||||||
<select
|
|
||||||
style={{ ...withError(!!errors.tripId), cursor: relLoading ? "wait" : "pointer" }}
|
|
||||||
disabled={relLoading}
|
|
||||||
{...register("tripId")}
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
<option value="">{relLoading ? "جارٍ التحميل…" : "اختر الرحلة"}</option>
|
|
||||||
{trips.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.tripNumber}
|
|
||||||
{t.title ? ` — ${t.title}` : ""}
|
|
||||||
{t.driver?.name ? ` (${t.driver.name})` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.tripId && <span style={errorTextStyle}>{errors.tripId.message}</span>}
|
|
||||||
<Link href="/dashboard/trips" style={createLinkStyle} tabIndex={-1}>
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
إنشاء رحلة جديدة
|
|
||||||
</Link>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
نوع الشحنة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("type")} placeholder="عادي / مبرد" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الكمية *
|
|
||||||
<input type="number" min={1} style={withError(!!errors.quantity)} {...numberField("quantity")} dir="ltr" />
|
|
||||||
{errors.quantity && <span style={errorTextStyle}>{errors.quantity.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الوزن (كجم)
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input type="number" min={0} step="0.01" style={inputBase} {...numberField("weight")} dir="ltr" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={sectionHeadingStyle}>بيانات الدفع</p>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الإجمالي الفرعي
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input type="number" min={0} step="0.01" style={withError(!!errors.subTotal)} {...numberField("subTotal")} dir="ltr" />
|
|
||||||
{errors.subTotal && <span style={errorTextStyle}>{errors.subTotal.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
|
||||||
نسبة الضريبة (%)
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input type="number" min={0} step="0.01" style={withError(!!errors.vatRate)} {...numberField("vatRate")} dir="ltr" />
|
|
||||||
{errors.vatRate && <span style={errorTextStyle}>{errors.vatRate.message}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={labelStyle}>
|
|
||||||
طريقة الدفع
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("paymentMethod")} dir="rtl">
|
|
||||||
<option value="">اختر الطريقة</option>
|
|
||||||
<option value="Cash">نقداً</option>
|
|
||||||
<option value="Card">بطاقة</option>
|
|
||||||
<option value="Prepaid">مدفوع مسبقاً</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{!isNew && (
|
|
||||||
<label style={labelStyle}>
|
|
||||||
حالة الدفع
|
|
||||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("paymentStatus")} dir="rtl">
|
|
||||||
<option value="">—</option>
|
|
||||||
<option value="Pending">معلَّق</option>
|
|
||||||
<option value="Paid">مدفوع</option>
|
|
||||||
<option value="Failed">فشل</option>
|
|
||||||
<option value="Refunded">مُسترجع</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={sectionHeadingStyle}>عنوان التسليم</p>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
المدينة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("deliveryAddress.details.city")} placeholder="الرياض" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الحي
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("deliveryAddress.details.district")} placeholder="العليا" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الشارع
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("deliveryAddress.details.street")} placeholder="شارع الأمير محمد" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم المبنى
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("deliveryAddress.details.buildingNo")} placeholder="1234" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم الوحدة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("deliveryAddress.details.unitNo")} placeholder="56" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الرمز البريدي
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("deliveryAddress.details.zipCode")} placeholder="12345" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
خط الطول (Longitude) *
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
style={withError(!!deliveryErr?.location?.coordinates)}
|
|
||||||
{...coordField("deliveryAddress.location.coordinates.0")}
|
|
||||||
placeholder="46.6753"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
خط العرض (Latitude) *
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
style={withError(!!deliveryErr?.location?.coordinates)}
|
|
||||||
{...coordField("deliveryAddress.location.coordinates.1")}
|
|
||||||
placeholder="24.7136"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{deliveryErr?.location?.coordinates && (
|
|
||||||
<span style={errorTextStyle}>{deliveryErr.location.coordinates.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}>
|
|
||||||
{(["id", "new"] as const).map((m) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPickupMode(m)}
|
|
||||||
style={{
|
|
||||||
height: 32,
|
|
||||||
padding: "0 0.875rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: `1px solid ${pickupMode === m ? "var(--color-brand-600)" : "var(--color-border)"}`,
|
|
||||||
background: pickupMode === m ? "var(--color-brand-50, #EFF6FF)" : "var(--color-surface)",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: pickupMode === m ? "var(--color-brand-600)" : "var(--color-text-secondary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m === "id" ? "عنوان موجود (ID)" : "عنوان جديد"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pickupMode === "id" ? (
|
|
||||||
<label style={labelStyle}>
|
|
||||||
معرّف العنوان (MongoDB ID)
|
|
||||||
<input
|
|
||||||
style={inputBase}
|
|
||||||
{...register("pickupAddressId")}
|
|
||||||
placeholder="67a1b2c3d4e5f6a7b8c9d0e1"
|
|
||||||
dir="ltr"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
المدينة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("pickupAddress.details.city")} placeholder="جدة" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الحي
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("pickupAddress.details.district")} placeholder="الروضة" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الشارع
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("pickupAddress.details.street")} placeholder="شارع التحلية" dir="rtl" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم المبنى
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("pickupAddress.details.buildingNo")} placeholder="4321" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم الوحدة
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("pickupAddress.details.unitNo")} placeholder="12" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الرمز البريدي
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input style={inputBase} {...register("pickupAddress.details.zipCode")} placeholder="23456" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
خط الطول (Longitude)
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
style={withError(!!pickupErr?.location?.coordinates)}
|
|
||||||
{...coordField("pickupAddress.location.coordinates.0")}
|
|
||||||
placeholder="46.6753"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
خط العرض (Latitude)
|
|
||||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
style={withError(!!pickupErr?.location?.coordinates)}
|
|
||||||
{...coordField("pickupAddress.location.coordinates.1")}
|
|
||||||
placeholder="24.7136"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{pickupErr?.location?.coordinates && (
|
|
||||||
<span style={errorTextStyle}>{pickupErr.location.coordinates.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
size="sm"
|
||||||
disabled={isSubmitting}
|
variant={pickupMode === m ? "primary" : "secondary"}
|
||||||
style={{
|
onClick={() => setPickupMode(m)}
|
||||||
height: 40,
|
|
||||||
padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
إلغاء
|
{m === "id" ? "عنوان موجود (ID)" : "عنوان جديد"}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
))}
|
||||||
type="submit"
|
</div>
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
{pickupMode === "id" ? (
|
||||||
height: 40,
|
<Input
|
||||||
padding: "0 1.5rem",
|
id="pickup-addressId"
|
||||||
borderRadius: "var(--radius-md)",
|
label="معرّف العنوان (MongoDB ID)"
|
||||||
border: "none",
|
{...register("pickupAddressId")}
|
||||||
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
placeholder="67a1b2c3d4e5f6a7b8c9d0e1"
|
||||||
fontSize: 13,
|
dir="ltr"
|
||||||
fontWeight: 700,
|
autoComplete="off"
|
||||||
color: "#FFF",
|
/>
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
) : (
|
||||||
display: "flex",
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
alignItems: "center",
|
<Input
|
||||||
gap: 8,
|
id="pickup-city"
|
||||||
fontFamily: "var(--font-sans)",
|
label="المدينة (اختياري)"
|
||||||
}}
|
{...register("pickupAddress.details.city")}
|
||||||
>
|
placeholder="جدة"
|
||||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
dir="rtl"
|
||||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إنشاء الطلب" : "حفظ التغييرات"}
|
/>
|
||||||
</button>
|
<Input
|
||||||
|
id="pickup-district"
|
||||||
|
label="الحي (اختياري)"
|
||||||
|
{...register("pickupAddress.details.district")}
|
||||||
|
placeholder="الروضة"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="pickup-street"
|
||||||
|
label="الشارع (اختياري)"
|
||||||
|
{...register("pickupAddress.details.street")}
|
||||||
|
placeholder="شارع التحلية"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="pickup-buildingNo"
|
||||||
|
label="رقم المبنى (اختياري)"
|
||||||
|
{...register("pickupAddress.details.buildingNo")}
|
||||||
|
placeholder="4321"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="pickup-unitNo"
|
||||||
|
label="رقم الوحدة (اختياري)"
|
||||||
|
{...register("pickupAddress.details.unitNo")}
|
||||||
|
placeholder="12"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="pickup-zipCode"
|
||||||
|
label="الرمز البريدي (اختياري)"
|
||||||
|
{...register("pickupAddress.details.zipCode")}
|
||||||
|
placeholder="23456"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="pickup-lng"
|
||||||
|
label="خط الطول (Longitude) (اختياري)"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
error={pickupErr?.location?.coordinates ? " " : undefined}
|
||||||
|
{...coordField("pickupAddress.location.coordinates.0")}
|
||||||
|
placeholder="46.6753"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="pickup-lat"
|
||||||
|
label="خط العرض (Latitude) (اختياري)"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
error={pickupErr?.location?.coordinates?.message}
|
||||||
|
{...coordField("pickupAddress.location.coordinates.1")}
|
||||||
|
placeholder="24.7136"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedOrderTable } from "./ArchivedOrderTable";
|
import { ArchivedOrderTable } from "./ArchivedOrderTable";
|
||||||
import { ArchivedOrderDetailModal } from "./ArchivedOrderDetailModal";
|
import { ArchivedOrderDetailModal } from "./ArchivedOrderDetailModal";
|
||||||
import { useArchivedOrders } from "@/src/hooks/archive/useArchivedOrders";
|
import { useArchivedOrders } from "@/src/hooks/archive/useArchivedOrders";
|
||||||
@@ -11,6 +11,21 @@ interface ArchivedOrdersModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
||||||
const [viewOrder, setViewOrder] = useState<ArchivedOrder | null>(null);
|
const [viewOrder, setViewOrder] = useState<ArchivedOrder | null>(null);
|
||||||
|
|
||||||
@@ -21,79 +36,28 @@ export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
|||||||
} = useArchivedOrders();
|
} = useArchivedOrders();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archive-orders-modal-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewOrder && (
|
{viewOrder && (
|
||||||
<ArchivedOrderDetailModal order={viewOrder} onClose={() => setViewOrder(null)} />
|
<ArchivedOrderDetailModal order={viewOrder} onClose={() => setViewOrder(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<Modal
|
||||||
onClick={e => e.stopPropagation()}
|
open
|
||||||
style={{
|
onClose={onClose}
|
||||||
width: "100%", maxWidth: 920,
|
size="lg"
|
||||||
background: "var(--color-surface-sunken)",
|
subtitle="الأرشيف"
|
||||||
borderRadius: "var(--radius-xl)",
|
title={`الطلبات المؤرشفة (${total})`}
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* header */}
|
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
<div dir="rtl" style={{
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
||||||
padding: "1.25rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
|
|
||||||
الأرشيف
|
|
||||||
</p>
|
|
||||||
<h2 id="archive-orders-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
الطلبات المؤرشفة
|
|
||||||
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
|
|
||||||
({total})
|
|
||||||
</span>
|
|
||||||
</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", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
||||||
{/* search */}
|
{/* search */}
|
||||||
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<Input
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
label="بحث"
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="رقم الشحنة أو المستلم..."
|
placeholder="رقم الشحنة أو المستلم..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
icon={searchIcon}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
|
||||||
width: "100%", height: 40,
|
|
||||||
paddingRight: 36, paddingLeft: 12,
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,7 +73,7 @@ export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, Input, Select, Textarea, Spinner } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import {
|
import {
|
||||||
createTripSchema,
|
createTripSchema,
|
||||||
@@ -22,34 +22,8 @@ import type { CarOption } from "@/src/types/car";
|
|||||||
import type { BranchOption } from "@/src/types/branch";
|
import type { BranchOption } from "@/src/types/branch";
|
||||||
|
|
||||||
// ── Shared styles ────────────────────────────────────────────────────────────
|
// ── Shared styles ────────────────────────────────────────────────────────────
|
||||||
|
// Only for section headings — everything field-level now comes from the
|
||||||
const inputBase: React.CSSProperties = {
|
// Input / Select / Textarea components themselves.
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorTextStyle: React.CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-danger)",
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectionHeadingStyle: React.CSSProperties = {
|
const sectionHeadingStyle: React.CSSProperties = {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -60,13 +34,6 @@ const sectionHeadingStyle: React.CSSProperties = {
|
|||||||
margin: "0.5rem 0 0",
|
margin: "0.5rem 0 0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const optionalLabelStyle: React.CSSProperties = {
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--color-text-hint)",
|
|
||||||
marginRight: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Form values shape ────────────────────────────────────────────────────────
|
// ── Form values shape ────────────────────────────────────────────────────────
|
||||||
// startTime/endTime store the ISO-8601 string (via setValueAs on register),
|
// startTime/endTime store the ISO-8601 string (via setValueAs on register),
|
||||||
// while the <input type="datetime-local"> DOM element itself keeps showing
|
// while the <input type="datetime-local"> DOM element itself keeps showing
|
||||||
@@ -127,9 +94,12 @@ export function TripFormModal({
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
|
setFocus,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<TripFormValues>({
|
} = useForm<TripFormValues>({
|
||||||
resolver: yupResolver(isNew ? createTripSchema : updateTripSchema) as never,
|
// Cast the schema itself — create/update schemas differ structurally in
|
||||||
|
// which fields are required, so yup can't unify them into one type.
|
||||||
|
resolver: yupResolver<TripFormValues>((isNew ? createTripSchema : updateTripSchema) as any),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: editTrip?.title ?? "",
|
title: editTrip?.title ?? "",
|
||||||
driverId: editTrip?.driverId ?? "",
|
driverId: editTrip?.driverId ?? "",
|
||||||
@@ -150,11 +120,13 @@ export function TripFormModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
const firstRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
|
// register("title") already attaches its own ref — Input forwards it
|
||||||
|
// through to the underlying <input>, so setFocus works with no separate
|
||||||
|
// ref needed here.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
firstRef.current?.focus();
|
setFocus("title");
|
||||||
}, []);
|
}, [setFocus]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = (e: KeyboardEvent) => {
|
const h = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@@ -163,11 +135,6 @@ export function TripFormModal({
|
|||||||
return () => window.removeEventListener("keydown", h);
|
return () => window.removeEventListener("keydown", h);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
|
||||||
...inputBase,
|
|
||||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const numberField = (
|
const numberField = (
|
||||||
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
|
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
|
||||||
) =>
|
) =>
|
||||||
@@ -182,6 +149,9 @@ export function TripFormModal({
|
|||||||
setValueAs: (v) => (v ? `${v}:00.000Z` : ""),
|
setValueAs: (v) => (v ? `${v}:00.000Z` : ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const titleError =
|
||||||
|
errors.title && errors.title.type !== "manual" ? errors.title.message : undefined;
|
||||||
|
|
||||||
// ── Submit ────────────────────────────────────────────────────────────────
|
// ── Submit ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const submitHandler = async (data: TripFormValues) => {
|
const submitHandler = async (data: TripFormValues) => {
|
||||||
@@ -273,22 +243,19 @@ export function TripFormModal({
|
|||||||
>
|
>
|
||||||
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
|
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
aria-label="إغلاق"
|
||||||
background: "none",
|
className="!px-1"
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
padding: 4,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
@@ -306,212 +273,186 @@ export function TripFormModal({
|
|||||||
<Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} />
|
<Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Section:trip management── */}
|
{/* ── Section: trip management ── */}
|
||||||
<p style={sectionHeadingStyle}>أساسيات الرحلة</p>
|
<p style={sectionHeadingStyle}>أساسيات الرحلة</p>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<label style={labelStyle}>
|
<Input
|
||||||
عنوان الرحلة <span style={{ color: "var(--color-danger)" }}>*</span>
|
label="عنوان الرحلة *"
|
||||||
<input
|
error={titleError}
|
||||||
ref={firstRef}
|
{...register("title")}
|
||||||
style={withError(!!errors.title && errors.title.type !== "manual")}
|
placeholder="مثال: توزيع الرياض الشمالي"
|
||||||
{...register("title")}
|
dir="rtl"
|
||||||
placeholder="مثال: توزيع الرياض الشمالي"
|
/>
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.title && errors.title.type !== "manual" && (
|
|
||||||
<span style={errorTextStyle}>{errors.title.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
{/* Driver */}
|
{/* Driver */}
|
||||||
<label style={labelStyle}>
|
<Select
|
||||||
السائق <span style={{ color: "var(--color-danger)" }}>*</span>
|
label="السائق *"
|
||||||
<select style={{ ...withError(!!errors.driverId), cursor: "pointer" }} {...register("driverId")} dir="rtl">
|
error={errors.driverId?.message}
|
||||||
<option value="">اختر السائق</option>
|
{...register("driverId")}
|
||||||
{drivers.map((d) => (
|
dir="rtl"
|
||||||
<option key={d.id} value={d.id}>
|
>
|
||||||
{d.name} — {d.phone}
|
<option value="">اختر السائق</option>
|
||||||
</option>
|
{drivers.map((d) => (
|
||||||
))}
|
<option key={d.id} value={d.id}>
|
||||||
</select>
|
{d.name} — {d.phone}
|
||||||
{errors.driverId && <span style={errorTextStyle}>{errors.driverId.message}</span>}
|
</option>
|
||||||
</label>
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
{/* Car */}
|
{/* Car */}
|
||||||
<label style={labelStyle}>
|
<Select
|
||||||
السيارة <span style={{ color: "var(--color-danger)" }}>*</span>
|
label="السيارة *"
|
||||||
<select style={{ ...withError(!!errors.carId), cursor: "pointer" }} {...register("carId")} dir="rtl">
|
error={errors.carId?.message}
|
||||||
<option value="">اختر السيارة</option>
|
{...register("carId")}
|
||||||
{cars.map((c) => (
|
dir="rtl"
|
||||||
<option key={c.id} value={c.id}>
|
>
|
||||||
{c.manufacturer} {c.model} — {c.plateNumber}
|
<option value="">اختر السيارة</option>
|
||||||
</option>
|
{cars.map((c) => (
|
||||||
))}
|
<option key={c.id} value={c.id}>
|
||||||
</select>
|
{c.manufacturer} {c.model} — {c.plateNumber}
|
||||||
{errors.carId && <span style={errorTextStyle}>{errors.carId.message}</span>}
|
</option>
|
||||||
</label>
|
))}
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
{/* Branch */}
|
{/* Branch */}
|
||||||
<label style={labelStyle}>
|
<Select
|
||||||
الفرع <span style={{ color: "var(--color-danger)" }}>*</span>
|
label="الفرع *"
|
||||||
<select style={{ ...withError(!!errors.branchId), cursor: "pointer" }} {...register("branchId")} dir="rtl">
|
error={errors.branchId?.message}
|
||||||
<option value="">اختر الفرع</option>
|
{...register("branchId")}
|
||||||
{branches.map((b) => (
|
dir="rtl"
|
||||||
<option key={b.id} value={b.id}>{b.name}</option>
|
>
|
||||||
))}
|
<option value="">اختر الفرع</option>
|
||||||
</select>
|
{branches.map((b) => (
|
||||||
{errors.branchId && <span style={errorTextStyle}>{errors.branchId.message}</span>}
|
<option key={b.id} value={b.id}>{b.name}</option>
|
||||||
</label>
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<label style={labelStyle}>
|
<Select label="الحالة" {...register("status")} dir="rtl">
|
||||||
الحالة
|
<option value="Scheduled">مجدولة</option>
|
||||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("status")} dir="rtl">
|
<option value="InProgress">جارية</option>
|
||||||
<option value="Scheduled">مجدولة</option>
|
<option value="Completed">مكتملة</option>
|
||||||
<option value="InProgress">جارية</option>
|
<option value="Cancelled">ملغاة</option>
|
||||||
<option value="Completed">مكتملة</option>
|
</Select>
|
||||||
<option value="Cancelled">ملغاة</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Section: التوقيت ── */}
|
{/* ── Section: التوقيت ── */}
|
||||||
<p style={sectionHeadingStyle}>التوقيت</p>
|
<p style={sectionHeadingStyle}>التوقيت</p>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<label style={labelStyle}>
|
<Input
|
||||||
وقت البدء <span style={optionalLabelStyle}>(اختياري)</span>
|
label="وقت البدء"
|
||||||
<input type="datetime-local" style={withError(!!errors.startTime)} {...dateTimeField("startTime")} />
|
hint="اختياري"
|
||||||
{errors.startTime && <span style={errorTextStyle}>{errors.startTime.message}</span>}
|
type="datetime-local"
|
||||||
</label>
|
error={errors.startTime?.message}
|
||||||
|
{...dateTimeField("startTime")}
|
||||||
<label style={labelStyle}>
|
/>
|
||||||
وقت الانتهاء <span style={optionalLabelStyle}>(اختياري)</span>
|
<Input
|
||||||
<input type="datetime-local" style={withError(!!errors.endTime)} {...dateTimeField("endTime")} />
|
label="وقت الانتهاء"
|
||||||
{errors.endTime && <span style={errorTextStyle}>{errors.endTime.message}</span>}
|
hint="اختياري"
|
||||||
</label>
|
type="datetime-local"
|
||||||
|
error={errors.endTime?.message}
|
||||||
|
{...dateTimeField("endTime")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Section: الأعداد والمبالغ ── */}
|
{/* ── Section: الأعداد والمبالغ ── */}
|
||||||
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
|
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
<label style={labelStyle}>
|
<Input
|
||||||
المجمّع <span style={optionalLabelStyle}>(اختياري)</span>
|
label="المجمّع"
|
||||||
<input type="number" min="0" style={withError(!!errors.collectedCount)} {...numberField("collectedCount")} placeholder="0" dir="ltr" />
|
hint="اختياري"
|
||||||
{errors.collectedCount && <span style={errorTextStyle}>{errors.collectedCount.message}</span>}
|
type="number"
|
||||||
</label>
|
min="0"
|
||||||
|
error={errors.collectedCount?.message}
|
||||||
<label style={labelStyle}>
|
{...numberField("collectedCount")}
|
||||||
المُسلَّم <span style={optionalLabelStyle}>(اختياري)</span>
|
placeholder="0"
|
||||||
<input type="number" min="0" style={withError(!!errors.deliveredCount)} {...numberField("deliveredCount")} placeholder="0" dir="ltr" />
|
dir="ltr"
|
||||||
{errors.deliveredCount && <span style={errorTextStyle}>{errors.deliveredCount.message}</span>}
|
/>
|
||||||
</label>
|
<Input
|
||||||
|
label="المُسلَّم"
|
||||||
<label style={labelStyle}>
|
hint="اختياري"
|
||||||
المُرتجع <span style={optionalLabelStyle}>(اختياري)</span>
|
type="number"
|
||||||
<input type="number" min="0" style={withError(!!errors.returnedCount)} {...numberField("returnedCount")} placeholder="0" dir="ltr" />
|
min="0"
|
||||||
{errors.returnedCount && <span style={errorTextStyle}>{errors.returnedCount.message}</span>}
|
error={errors.deliveredCount?.message}
|
||||||
</label>
|
{...numberField("deliveredCount")}
|
||||||
|
placeholder="0"
|
||||||
<label style={labelStyle}>
|
dir="ltr"
|
||||||
النقد المحصّل <span style={optionalLabelStyle}>(اختياري)</span>
|
/>
|
||||||
<input type="number" min="0" step="0.01" style={withError(!!errors.totalCashCollected)} {...numberField("totalCashCollected")} placeholder="0.00" dir="ltr" />
|
<Input
|
||||||
{errors.totalCashCollected && <span style={errorTextStyle}>{errors.totalCashCollected.message}</span>}
|
label="المُرتجع"
|
||||||
</label>
|
hint="اختياري"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
error={errors.returnedCount?.message}
|
||||||
|
{...numberField("returnedCount")}
|
||||||
|
placeholder="0"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="النقد المحصّل"
|
||||||
|
hint="اختياري"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
error={errors.totalCashCollected?.message}
|
||||||
|
{...numberField("totalCashCollected")}
|
||||||
|
placeholder="0.00"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Section: ملاحظات ── */}
|
{/* ── Section: ملاحظات ── */}
|
||||||
<p style={sectionHeadingStyle}>ملاحظات</p>
|
<p style={sectionHeadingStyle}>ملاحظات</p>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<label style={labelStyle}>
|
<Textarea
|
||||||
ملاحظات <span style={optionalLabelStyle}>(اختياري)</span>
|
label="ملاحظات"
|
||||||
<textarea
|
hint="اختياري"
|
||||||
style={{ ...withError(!!errors.notes), height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }}
|
error={errors.notes?.message}
|
||||||
{...register("notes")}
|
{...register("notes")}
|
||||||
placeholder="أي ملاحظات إضافية…"
|
placeholder="أي ملاحظات إضافية…"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
className="h-[72px]"
|
||||||
{errors.notes && <span style={errorTextStyle}>{errors.notes.message}</span>}
|
/>
|
||||||
</label>
|
<Textarea
|
||||||
|
label="سبب الإنهاء"
|
||||||
<label style={labelStyle}>
|
hint="اختياري"
|
||||||
سبب الإنهاء <span style={optionalLabelStyle}>(اختياري)</span>
|
error={errors.endReason?.message}
|
||||||
<textarea
|
{...register("endReason")}
|
||||||
style={{ ...withError(!!errors.endReason), height: 72, padding: "0.5rem 0.75rem", resize: "vertical" }}
|
placeholder="سبب إنهاء أو إلغاء الرحلة…"
|
||||||
{...register("endReason")}
|
dir="rtl"
|
||||||
placeholder="سبب إنهاء أو إلغاء الرحلة…"
|
className="h-[72px]"
|
||||||
dir="rtl"
|
/>
|
||||||
/>
|
|
||||||
{errors.endReason && <span style={errorTextStyle}>{errors.endReason.message}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* reason — edit only (for reassigning driver/car) */}
|
{/* reason — edit only (for reassigning driver/car) */}
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<>
|
<>
|
||||||
<p style={sectionHeadingStyle}>سجل التغيير</p>
|
<p style={sectionHeadingStyle}>سجل التغيير</p>
|
||||||
<label style={labelStyle}>
|
<Input
|
||||||
سبب التعديل{" "}
|
label="سبب التعديل"
|
||||||
<span style={optionalLabelStyle}>(مطلوب عند تغيير السائق أو السيارة)</span>
|
hint="مطلوب عند تغيير السائق أو السيارة"
|
||||||
<input
|
{...register("reason")}
|
||||||
style={inputBase}
|
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
|
||||||
{...register("reason")}
|
dir="rtl"
|
||||||
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
|
/>
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 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
|
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إلغاء
|
إلغاء
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit" variant="primary" loading={isSubmitting}>
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.5rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
|
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner, Alert, EmptyState } from "@/src/Components/UI";
|
import { Spinner, Alert, EmptyState, Input, Button } from "@/src/Components/UI";
|
||||||
import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips";
|
import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips";
|
||||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||||
import type { Trip } from "@/src/types/trip";
|
import type { Trip } from "@/src/types/trip";
|
||||||
@@ -11,6 +11,8 @@ interface ArchivedTripListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Status badge — reuses the existing trip status color map ────────────────
|
// ── Status badge — reuses the existing trip status color map ────────────────
|
||||||
|
// (per-status colors come from TRIP_STATUS_MAP, so the shared Badge component
|
||||||
|
// — which only supports a fixed palette — doesn't cover this; kept custom.)
|
||||||
function TripStatusBadge({ status }: { status: Trip["status"] }) {
|
function TripStatusBadge({ status }: { status: Trip["status"] }) {
|
||||||
const s = TRIP_STATUS_MAP[status];
|
const s = TRIP_STATUS_MAP[status];
|
||||||
return (
|
return (
|
||||||
@@ -30,6 +32,21 @@ function fmtDate(iso?: string | null): string {
|
|||||||
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ArchivedTripList
|
* ArchivedTripList
|
||||||
* Displays a paginated, searchable list of archived trips.
|
* Displays a paginated, searchable list of archived trips.
|
||||||
@@ -56,19 +73,14 @@ export function ArchivedTripList({ onView }: ArchivedTripListProps) {
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="relative w-full sm:w-72">
|
<div className="w-full sm:w-72">
|
||||||
<svg
|
<Input
|
||||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-text-hint)]"
|
label="بحث"
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="بحث برقم الرحلة أو العنوان..."
|
placeholder="بحث برقم الرحلة أو العنوان..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
className="h-10 w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] pr-9 pl-3 text-[13px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15"
|
icon={searchIcon}
|
||||||
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,22 +154,22 @@ export function ArchivedTripList({ onView }: ArchivedTripListProps) {
|
|||||||
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
|
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
onClick={() => setPage(Math.max(1, page - 1))}
|
onClick={() => setPage(Math.max(1, page - 1))}
|
||||||
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
السابق
|
السابق
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
disabled={page === pages}
|
disabled={page === pages}
|
||||||
onClick={() => setPage(Math.min(pages, page + 1))}
|
onClick={() => setPage(Math.min(pages, page + 1))}
|
||||||
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
التالي
|
التالي
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Accessible labelled input with error and hint state.
|
// Accessible labelled input with error and hint state.
|
||||||
|
|
||||||
|
import { forwardRef } from "react";
|
||||||
import { cn } from "@/src/lib/utils";
|
import { cn } from "@/src/lib/utils";
|
||||||
|
|
||||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
@@ -29,7 +30,10 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
|
|||||||
* hint="At least 8 characters"
|
* hint="At least 8 characters"
|
||||||
* />
|
* />
|
||||||
*/
|
*/
|
||||||
export function Input({ label, error, hint, id, icon, className, ...rest }: InputProps) {
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||||
|
{ label, error, hint, id, icon, className, ...rest },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||||
const errorId = `${inputId}-error`;
|
const errorId = `${inputId}-error`;
|
||||||
const hintId = `${inputId}-hint`;
|
const hintId = `${inputId}-hint`;
|
||||||
@@ -54,6 +58,7 @@ export function Input({ label, error, hint, id, icon, className, ...rest }: Inpu
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
ref={ref}
|
||||||
id={inputId}
|
id={inputId}
|
||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
@@ -88,4 +93,4 @@ export function Input({ label, error, hint, id, icon, className, ...rest }: Inpu
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
@@ -23,4 +23,5 @@ export { PageHeader } from "./PageHeader";
|
|||||||
export { EmptyState } from "./EmptyState";
|
export { EmptyState } from "./EmptyState";
|
||||||
export { Badge } from "./Badge";
|
export { Badge } from "./Badge";
|
||||||
|
|
||||||
export { ArchiveButton } from "./ArchiveButton"
|
export { ArchiveButton } from "./ArchiveButton"
|
||||||
|
export { FileInput } from "./FailInput"
|
||||||
@@ -2,30 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, Input, Select, Spinner } 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 { FormErrors, User, UserFormData } from "@/src/types/user";
|
||||||
|
|
||||||
// ── fixed styles ─────────────────────────────────────────
|
|
||||||
const S = {
|
|
||||||
input: {
|
|
||||||
width: "100%", height: 40, padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, color: "var(--color-text-primary)",
|
|
||||||
outline: "none", fontFamily: "var(--font-sans)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
label: {
|
|
||||||
display: "flex", flexDirection: "column" as const,
|
|
||||||
gap: 6, fontSize: 12, fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── yup validation ────────────────────────────────────────────────────────────
|
// ── yup validation ────────────────────────────────────────────────────────────
|
||||||
async function validate(data: UserFormData, isNew: boolean): Promise<FormErrors> {
|
async function validate(data: UserFormData, isNew: boolean): Promise<FormErrors> {
|
||||||
const schema = isNew ? createUserSchema : updateUserSchema;
|
const schema = isNew ? createUserSchema : updateUserSchema;
|
||||||
@@ -103,12 +85,6 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||||
};
|
};
|
||||||
|
|
||||||
// dynamic styles for inputs with errors
|
|
||||||
const inputStyle = (field: keyof FormErrors): React.CSSProperties => ({
|
|
||||||
...S.input,
|
|
||||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog" aria-modal="true" aria-labelledby="modal-title"
|
role="dialog" aria-modal="true" aria-labelledby="modal-title"
|
||||||
@@ -145,10 +121,15 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
{isNew ? "مستخدم جديد" : editUser?.name}
|
{isNew ? "مستخدم جديد" : editUser?.name}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<Button
|
||||||
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{ width: 34, height: 34, padding: 0, fontSize: 18 }}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
@@ -156,64 +137,85 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
|||||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
||||||
|
|
||||||
{/* name */}
|
{/* name */}
|
||||||
<label style={S.label}>
|
<Input
|
||||||
الاسم الكامل *
|
ref={firstInputRef}
|
||||||
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="أحمد الرشيدي" autoComplete="name" dir="rtl" />
|
label="الاسم الكامل *"
|
||||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
value={form.name}
|
||||||
</label>
|
onChange={set("name")}
|
||||||
|
error={errors.name}
|
||||||
|
placeholder="أحمد الرشيدي"
|
||||||
|
autoComplete="name"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* email + phone */}
|
{/* email + phone */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<label style={S.label}>
|
<Input
|
||||||
البريد الإلكتروني
|
label="البريد الإلكتروني"
|
||||||
<input style={inputStyle("email")} type="email" value={form.email} onChange={set("email")} placeholder="ahmed@co.sa" autoComplete="email" dir="ltr" />
|
type="email"
|
||||||
{errors.email && <span style={S.errorText}>{errors.email}</span>}
|
value={form.email}
|
||||||
</label>
|
onChange={set("email")}
|
||||||
<label style={S.label}>
|
error={errors.email}
|
||||||
رقم الهاتف *
|
placeholder="ahmed@co.sa"
|
||||||
<input style={inputStyle("phone")} type="tel" value={form.phone} onChange={set("phone")} placeholder="+966 5x xxx xxxx" autoComplete="tel" dir="ltr" />
|
autoComplete="email"
|
||||||
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
|
dir="ltr"
|
||||||
</label>
|
/>
|
||||||
|
<Input
|
||||||
|
label="رقم الهاتف *"
|
||||||
|
type="tel"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={set("phone")}
|
||||||
|
error={errors.phone}
|
||||||
|
placeholder="+966 5x xxx xxxx"
|
||||||
|
autoComplete="tel"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* password */}
|
{/* password */}
|
||||||
<label style={S.label}>
|
<Input
|
||||||
{isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"}
|
label={isNew ? "كلمة المرور *" : "كلمة المرور الجديدة (اتركها فارغة إذا لا تريد تغييرها)"}
|
||||||
<input style={inputStyle("password")} type="password" value={form.password} onChange={set("password")} placeholder="••••••••" autoComplete={isNew ? "new-password" : "off"} dir="ltr" />
|
type="password"
|
||||||
{errors.password && <span style={S.errorText}>{errors.password}</span>}
|
value={form.password}
|
||||||
</label>
|
onChange={set("password")}
|
||||||
|
error={errors.password}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete={isNew ? "new-password" : "off"}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* role + branch */}
|
{/* role + branch */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<label style={S.label}>
|
<Select
|
||||||
الدور *
|
label="الدور *"
|
||||||
<select style={{ ...inputStyle("roleId"), cursor: "pointer" }} value={form.roleId} onChange={set("roleId")} dir="rtl">
|
value={form.roleId}
|
||||||
<option value="">اختر الدور</option>
|
onChange={set("roleId")}
|
||||||
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
error={errors.roleId}
|
||||||
</select>
|
dir="rtl"
|
||||||
{errors.roleId && <span style={S.errorText}>{errors.roleId}</span>}
|
>
|
||||||
</label>
|
<option value="">اختر الدور</option>
|
||||||
<label style={S.label}>
|
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
الفرع *
|
</Select>
|
||||||
<select style={{ ...inputStyle("branchId"), cursor: "pointer" }} value={form.branchId} onChange={set("branchId")} dir="rtl">
|
<Select
|
||||||
<option value="">اختر الفرع</option>
|
label="الفرع *"
|
||||||
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
value={form.branchId}
|
||||||
</select>
|
onChange={set("branchId")}
|
||||||
{errors.branchId && <span style={S.errorText}>{errors.branchId}</span>}
|
error={errors.branchId}
|
||||||
</label>
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر الفرع</option>
|
||||||
|
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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" onClick={onClose} disabled={saving}
|
<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>
|
||||||
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
إلغاء
|
إلغاء
|
||||||
</button>
|
</Button>
|
||||||
<button type="submit" disabled={saving}
|
<Button type="submit" variant="primary" loading={saving}>
|
||||||
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
|
||||||
{saving && <Spinner size="sm" className="text-white" />}
|
|
||||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة المستخدم" : "حفظ التغييرات"}
|
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة المستخدم" : "حفظ التغييرات"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedUserTable } from "./Archivedusertable";
|
import { ArchivedUserTable } from "./Archivedusertable";
|
||||||
import { ArchivedUserDetailModal } from "./Archiveduserdetailmodal";
|
import { ArchivedUserDetailModal } from "./Archiveduserdetailmodal";
|
||||||
import { useArchivedUsers } from "@/src/hooks/archive/useArchivedUsers";
|
import { useArchivedUsers } from "@/src/hooks/archive/useArchivedUsers";
|
||||||
@@ -10,6 +10,21 @@ interface ArchivedUsersModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
||||||
const [viewUserId, setViewUserId] = useState<string | null>(null);
|
const [viewUserId, setViewUserId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -20,79 +35,27 @@ export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
|||||||
} = useArchivedUsers();
|
} = useArchivedUsers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archive-modal-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewUserId && (
|
{viewUserId && (
|
||||||
<ArchivedUserDetailModal userId={viewUserId} onClose={() => setViewUserId(null)} />
|
<ArchivedUserDetailModal userId={viewUserId} onClose={() => setViewUserId(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<Modal
|
||||||
onClick={e => e.stopPropagation()}
|
open
|
||||||
style={{
|
onClose={onClose}
|
||||||
width: "100%", maxWidth: 920,
|
size="lg"
|
||||||
background: "var(--color-surface-sunken)",
|
subtitle="الأرشيف"
|
||||||
borderRadius: "var(--radius-xl)",
|
title={`المستخدمون المؤرشفون (${total})`}
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* header */}
|
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
<div dir="rtl" style={{
|
<div style={{ maxWidth: 320 }}>
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
<Input
|
||||||
padding: "1.25rem 1.5rem",
|
label="بحث"
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
|
|
||||||
الأرشيف
|
|
||||||
</p>
|
|
||||||
<h2 id="archive-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
المستخدمون المؤرشفون
|
|
||||||
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
|
|
||||||
({total})
|
|
||||||
</span>
|
|
||||||
</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", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
||||||
{/* search */}
|
|
||||||
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
|
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="بحث بالاسم أو الهاتف..."
|
placeholder="بحث بالاسم أو الهاتف..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
icon={searchIcon}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
|
||||||
width: "100%", height: 40,
|
|
||||||
paddingRight: 36, paddingLeft: 12,
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +71,7 @@ export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, Input, Modal, Select } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import {
|
import {
|
||||||
createCarSchema,
|
createCarSchema,
|
||||||
@@ -18,35 +18,9 @@ import type {
|
|||||||
import type { Branch } from "@/src/types/branch";
|
import type { Branch } from "@/src/types/branch";
|
||||||
import { branchService } from "@/src/services/branch.service";
|
import { branchService } from "@/src/services/branch.service";
|
||||||
|
|
||||||
// ── Shared input style ────────────────────────────────────────────────────────
|
// ── Shared styles ─────────────────────────────────────────────────────────────
|
||||||
|
// Only section-heading styling is left here — every input/select/label/error
|
||||||
const inputBase: React.CSSProperties = {
|
// visual is now owned by the shared <Input /> / <Select /> components.
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorTextStyle: React.CSSProperties = {
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-danger)",
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectionHeadingStyle: React.CSSProperties = {
|
const sectionHeadingStyle: React.CSSProperties = {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -57,6 +31,8 @@ const sectionHeadingStyle: React.CSSProperties = {
|
|||||||
margin: "0.5rem 0 0",
|
margin: "0.5rem 0 0",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FORM_ID = "car-form";
|
||||||
|
|
||||||
// ── Date helper ───────────────────────────────────────────────────────────────
|
// ── Date helper ───────────────────────────────────────────────────────────────
|
||||||
// <input type="date"> gives "YYYY-MM-DD"; backend expects full ISO-8601.
|
// <input type="date"> gives "YYYY-MM-DD"; backend expects full ISO-8601.
|
||||||
|
|
||||||
@@ -140,9 +116,13 @@ export function CarFormModal({
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
|
setFocus,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<CarFormValues>({
|
} = useForm<CarFormValues>({
|
||||||
resolver: yupResolver(isNew ? createCarSchema : updateCarSchema) as never,
|
// The create/update schemas have structurally different required fields,
|
||||||
|
// so yup's inferred types don't unify — cast the schema itself (not the
|
||||||
|
// yupResolver() call) to sidestep the mismatch.
|
||||||
|
resolver: yupResolver<CarFormValues>((isNew ? createCarSchema : updateCarSchema) as any),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
manufacturer: editCar?.manufacturer ?? "",
|
manufacturer: editCar?.manufacturer ?? "",
|
||||||
model: editCar?.model ?? "",
|
model: editCar?.model ?? "",
|
||||||
@@ -169,18 +149,14 @@ export function CarFormModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
const firstRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
|
// register("manufacturer") already provides a ref for this input, so we
|
||||||
|
// focus it via RHF's setFocus instead of a separate useRef — assigning
|
||||||
|
// both `ref={firstRef}` and `{...register(...)}` on the same element
|
||||||
|
// causes the second ref to silently overwrite the first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
firstRef.current?.focus();
|
setFocus("manufacturer");
|
||||||
}, []);
|
}, [setFocus]);
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
// Numeric fields: empty string -> undefined (never NaN), matching the
|
// Numeric fields: empty string -> undefined (never NaN), matching the
|
||||||
// behavior of the old parseNum() helper.
|
// behavior of the old parseNum() helper.
|
||||||
@@ -191,11 +167,6 @@ export function CarFormModal({
|
|||||||
setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)),
|
setValueAs: (v) => (v === "" || v === null ? undefined : Number(v)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
|
||||||
...inputBase,
|
|
||||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Submit ────────────────────────────────────────────────────────────────
|
// ── Submit ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const submitHandler = async (data: CarFormValues) => {
|
const submitHandler = async (data: CarFormValues) => {
|
||||||
@@ -244,351 +215,173 @@ export function CarFormModal({
|
|||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
title={isNew ? "مركبة جديدة" : `${editCar?.manufacturer} ${editCar?.model}`}
|
||||||
aria-labelledby="car-modal-title"
|
subtitle={isNew ? "إضافة مركبة" : "تعديل مركبة"}
|
||||||
onClick={(e) => {
|
onClose={onClose}
|
||||||
if (e.target === e.currentTarget) onClose();
|
size="lg"
|
||||||
}}
|
footer={
|
||||||
style={{
|
<>
|
||||||
position: "fixed",
|
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
inset: 0,
|
إلغاء
|
||||||
zIndex: 50,
|
</Button>
|
||||||
background: "rgba(15,23,42,0.55)",
|
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
|
||||||
backdropFilter: "blur(4px)",
|
{isNew ? "إضافة المركبة" : "حفظ التغييرات"}
|
||||||
display: "flex",
|
</Button>
|
||||||
alignItems: "center",
|
</>
|
||||||
justifyContent: "center",
|
}
|
||||||
padding: "1rem",
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<form
|
||||||
onClick={(e) => e.stopPropagation()}
|
id={FORM_ID}
|
||||||
style={{
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
width: "100%",
|
noValidate
|
||||||
maxWidth: 620,
|
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
background: "var(--color-surface)",
|
dir="rtl"
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden",
|
|
||||||
margin: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{errors.manufacturer?.type === "manual" && (
|
||||||
<div
|
<Alert
|
||||||
style={{
|
type="error"
|
||||||
display: "flex",
|
message={errors.manufacturer.message ?? ""}
|
||||||
alignItems: "center",
|
onClose={() => setApiError("")}
|
||||||
justifyContent: "space-between",
|
/>
|
||||||
padding: "1.25rem 1.5rem",
|
)}
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface-muted)",
|
{/* ── Section: Basic Info ── */}
|
||||||
}}
|
<p style={sectionHeadingStyle}>بيانات أساسية</p>
|
||||||
>
|
|
||||||
<div>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<p
|
<Input
|
||||||
style={{
|
label="الشركة المصنعة *"
|
||||||
fontSize: 11,
|
{...register("manufacturer")}
|
||||||
letterSpacing: "0.3em",
|
error={
|
||||||
textTransform: "uppercase",
|
errors.manufacturer && errors.manufacturer.type !== "manual"
|
||||||
color: "#2563EB",
|
? errors.manufacturer.message
|
||||||
fontWeight: 600,
|
: undefined
|
||||||
margin: 0,
|
}
|
||||||
}}
|
placeholder="تويوتا"
|
||||||
>
|
dir="rtl"
|
||||||
{isNew ? "إضافة مركبة" : "تعديل مركبة"}
|
autoComplete="off"
|
||||||
</p>
|
/>
|
||||||
<h2
|
<Input
|
||||||
id="car-modal-title"
|
label="الموديل *"
|
||||||
style={{
|
{...register("model")}
|
||||||
fontSize: 17,
|
error={errors.model?.message}
|
||||||
fontWeight: 700,
|
placeholder="لاند كروزر"
|
||||||
color: "var(--color-text-primary)",
|
dir="rtl"
|
||||||
margin: "4px 0 0",
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isNew ? "مركبة جديدة" : `${editCar?.manufacturer} ${editCar?.model}`}
|
|
||||||
</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>
|
||||||
|
|
||||||
{/* Form */}
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
<form
|
<Input
|
||||||
onSubmit={handleSubmit(submitHandler)}
|
label="سنة الصنع *"
|
||||||
noValidate
|
type="number"
|
||||||
style={{
|
min={1900}
|
||||||
padding: "1.5rem",
|
max={new Date().getFullYear() + 1}
|
||||||
display: "flex",
|
{...numberField("year")}
|
||||||
flexDirection: "column",
|
error={errors.year?.message}
|
||||||
gap: "1rem",
|
dir="ltr"
|
||||||
maxHeight: "75vh",
|
/>
|
||||||
overflowY: "auto",
|
<Input label="اللون" {...register("color")} placeholder="أبيض" dir="rtl" />
|
||||||
}}
|
<Input label="نوع اللوحة" {...register("plateType")} placeholder="خاص" dir="rtl" />
|
||||||
dir="rtl"
|
</div>
|
||||||
>
|
|
||||||
{errors.manufacturer?.type === "manual" && (
|
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
message={errors.manufacturer.message ?? ""}
|
|
||||||
onClose={() => setApiError("")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Section: Basic Info ── */}
|
{/* ── Section: Plate ── */}
|
||||||
<p style={sectionHeadingStyle}>بيانات أساسية</p>
|
<p style={sectionHeadingStyle}>بيانات اللوحة</p>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Input
|
||||||
|
label="رقم اللوحة *"
|
||||||
|
{...register("plateNumber")}
|
||||||
|
error={errors.plateNumber?.message}
|
||||||
|
placeholder="1234"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="حروف اللوحة *"
|
||||||
|
{...register("plateLetters")}
|
||||||
|
error={errors.plateLetters?.message}
|
||||||
|
placeholder="أ ب ج"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
{/* ── Section: Registration & Legal ── */}
|
||||||
<label style={labelStyle}>
|
<p style={sectionHeadingStyle}>الترخيص والتأمين</p>
|
||||||
الشركة المصنعة *
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<input
|
<Input
|
||||||
ref={firstRef}
|
label="رقم الاستمارة *"
|
||||||
style={withError(!!errors.manufacturer && errors.manufacturer.type !== "manual")}
|
{...register("registrationNumber")}
|
||||||
{...register("manufacturer")}
|
error={errors.registrationNumber?.message}
|
||||||
placeholder="تويوتا"
|
placeholder="SA-001234"
|
||||||
dir="rtl"
|
dir="ltr"
|
||||||
autoComplete="off"
|
/>
|
||||||
/>
|
<Input
|
||||||
{errors.manufacturer && errors.manufacturer.type !== "manual" && (
|
label="رقم الهيكل (VIN)"
|
||||||
<span style={errorTextStyle}>{errors.manufacturer.message}</span>
|
{...register("vinNumber")}
|
||||||
)}
|
error={errors.vinNumber?.message}
|
||||||
</label>
|
placeholder="1HGBH41JXMN109186"
|
||||||
<label style={labelStyle}>
|
dir="ltr"
|
||||||
الموديل *
|
/>
|
||||||
<input
|
<Input
|
||||||
style={withError(!!errors.model)}
|
label="انتهاء الاستمارة"
|
||||||
{...register("model")}
|
type="date"
|
||||||
placeholder="لاند كروزر"
|
{...register("registrationExpiryDate")}
|
||||||
dir="rtl"
|
/>
|
||||||
/>
|
<Select label="حالة التأمين" {...register("insuranceStatus")} dir="rtl">
|
||||||
{errors.model && <span style={errorTextStyle}>{errors.model.message}</span>}
|
<option value="Valid">سارٍ</option>
|
||||||
</label>
|
<option value="Expired">منتهي</option>
|
||||||
</div>
|
<option value="NotInsured">غير مؤمَّن</option>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
label="انتهاء التأمين"
|
||||||
|
type="date"
|
||||||
|
{...register("insuranceExpiryDate")}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="انتهاء الفحص الدوري"
|
||||||
|
type="date"
|
||||||
|
{...register("inspectionExpiryDate")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
{/* ── Section: Operational ── */}
|
||||||
<label style={labelStyle}>
|
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
|
||||||
سنة الصنع *
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
<input
|
<Select label="الفرع" {...register("branchId")} dir="rtl">
|
||||||
style={withError(!!errors.year)}
|
<option value="">اختر الفرع</option>
|
||||||
type="number"
|
{branches.map((b) => (
|
||||||
min={1900}
|
<option key={b.id} value={b.id}>
|
||||||
max={new Date().getFullYear() + 1}
|
{b.name}
|
||||||
{...numberField("year")}
|
</option>
|
||||||
dir="ltr"
|
))}
|
||||||
/>
|
</Select>
|
||||||
{errors.year && <span style={errorTextStyle}>{errors.year.message}</span>}
|
<Select label="الحالة" {...register("currentStatus")} dir="rtl">
|
||||||
</label>
|
<option value="Active">نشط</option>
|
||||||
<label style={labelStyle}>
|
<option value="InMaintenance">صيانة</option>
|
||||||
اللون
|
<option value="InTrip">في رحلة</option>
|
||||||
<input style={inputBase} {...register("color")} placeholder="أبيض" dir="rtl" />
|
<option value="Inactive">غير نشط</option>
|
||||||
</label>
|
</Select>
|
||||||
<label style={labelStyle}>
|
<Input label="رقم GPS" {...register("gpsDeviceId")} placeholder="GPS-001" dir="ltr" />
|
||||||
نوع اللوحة
|
<Input
|
||||||
<input style={inputBase} {...register("plateType")} placeholder="خاص" dir="rtl" />
|
label="الطاقة الاستيعابية"
|
||||||
</label>
|
type="number"
|
||||||
</div>
|
min={0}
|
||||||
|
{...numberField("capacity")}
|
||||||
{/* ── Section: Plate ── */}
|
error={errors.capacity?.message}
|
||||||
<p style={sectionHeadingStyle}>بيانات اللوحة</p>
|
placeholder="0"
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
dir="ltr"
|
||||||
<label style={labelStyle}>
|
/>
|
||||||
رقم اللوحة *
|
<Input
|
||||||
<input
|
label="الوزن (كجم)"
|
||||||
style={withError(!!errors.plateNumber)}
|
type="number"
|
||||||
{...register("plateNumber")}
|
min={0}
|
||||||
placeholder="1234"
|
{...numberField("weight")}
|
||||||
dir="ltr"
|
error={errors.weight?.message}
|
||||||
/>
|
placeholder="0"
|
||||||
{errors.plateNumber && (
|
dir="ltr"
|
||||||
<span style={errorTextStyle}>{errors.plateNumber.message}</span>
|
/>
|
||||||
)}
|
</div>
|
||||||
</label>
|
</form>
|
||||||
<label style={labelStyle}>
|
</Modal>
|
||||||
حروف اللوحة *
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.plateLetters)}
|
|
||||||
{...register("plateLetters")}
|
|
||||||
placeholder="أ ب ج"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.plateLetters && (
|
|
||||||
<span style={errorTextStyle}>{errors.plateLetters.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Section: Registration & Legal ── */}
|
|
||||||
<p style={sectionHeadingStyle}>الترخيص والتأمين</p>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم الاستمارة *
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.registrationNumber)}
|
|
||||||
{...register("registrationNumber")}
|
|
||||||
placeholder="SA-001234"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.registrationNumber && (
|
|
||||||
<span style={errorTextStyle}>{errors.registrationNumber.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم الهيكل (VIN)
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.vinNumber)}
|
|
||||||
{...register("vinNumber")}
|
|
||||||
placeholder="1HGBH41JXMN109186"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.vinNumber && (
|
|
||||||
<span style={errorTextStyle}>{errors.vinNumber.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
انتهاء الاستمارة
|
|
||||||
<input style={inputBase} type="date" {...register("registrationExpiryDate")} />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
حالة التأمين
|
|
||||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("insuranceStatus")} dir="rtl">
|
|
||||||
<option value="Valid">سارٍ</option>
|
|
||||||
<option value="Expired">منتهي</option>
|
|
||||||
<option value="NotInsured">غير مؤمَّن</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
انتهاء التأمين
|
|
||||||
<input style={inputBase} type="date" {...register("insuranceExpiryDate")} />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
انتهاء الفحص الدوري
|
|
||||||
<input style={inputBase} type="date" {...register("inspectionExpiryDate")} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Section: Operational ── */}
|
|
||||||
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الفرع
|
|
||||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("branchId")} dir="rtl">
|
|
||||||
<option value="">اختر الفرع</option>
|
|
||||||
{branches.map((b) => (
|
|
||||||
<option key={b.id} value={b.id}>
|
|
||||||
{b.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الحالة
|
|
||||||
<select style={{ ...inputBase, cursor: "pointer" }} {...register("currentStatus")} dir="rtl">
|
|
||||||
<option value="Active">نشط</option>
|
|
||||||
<option value="InMaintenance">صيانة</option>
|
|
||||||
<option value="InTrip">في رحلة</option>
|
|
||||||
<option value="Inactive">غير نشط</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
رقم GPS
|
|
||||||
<input style={inputBase} {...register("gpsDeviceId")} placeholder="GPS-001" dir="ltr" />
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الطاقة الاستيعابية
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.capacity)}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
{...numberField("capacity")}
|
|
||||||
placeholder="0"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.capacity && (
|
|
||||||
<span style={errorTextStyle}>{errors.capacity.message}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
الوزن (كجم)
|
|
||||||
<input
|
|
||||||
style={withError(!!errors.weight)}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
{...numberField("weight")}
|
|
||||||
placeholder="0"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
{errors.weight && <span style={errorTextStyle}>{errors.weight.message}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Actions ── */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.25rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
إلغاء
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: "0 1.5rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "none",
|
|
||||||
background: isSubmitting ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#FFF",
|
|
||||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
|
||||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة المركبة" : "حفظ التغييرات"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Spinner } from "../../UI";
|
import { Spinner, Button } from "../../UI";
|
||||||
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
|
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
|
||||||
import type { Car, InsuranceStatus } from "@/src/types/car";
|
import type { Car, InsuranceStatus } from "@/src/types/car";
|
||||||
|
|
||||||
@@ -78,10 +78,15 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
|||||||
{car ? `${car.manufacturer} ${car.model}` : "عرض المركبة"}
|
{car ? `${car.manufacturer} ${car.model}` : "عرض المركبة"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<Button
|
||||||
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
@@ -138,10 +143,9 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
|||||||
|
|
||||||
{/* footer */}
|
{/* footer */}
|
||||||
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end" }}>
|
<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}
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
|
|
||||||
إغلاق
|
إغلاق
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Button } from "../../UI";
|
||||||
import { ArchivedCarTable } from "./Archivedcartable";
|
import { ArchivedCarTable } from "./Archivedcartable";
|
||||||
import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel";
|
import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel";
|
||||||
import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars";
|
import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars";
|
||||||
@@ -69,36 +69,33 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<Button
|
||||||
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
{/* search */}
|
{/* search */}
|
||||||
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<Input
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
label="بحث"
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="بحث بالماركة أو اللوحة..."
|
placeholder="بحث بالماركة أو اللوحة..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
icon={
|
||||||
width: "100%", height: 40,
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
paddingRight: 36, paddingLeft: 12,
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
borderRadius: "var(--radius-lg)",
|
</svg>
|
||||||
border: "1px solid var(--color-border)",
|
}
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Spinner, Button } from "../../UI";
|
||||||
import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
|
import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
|
||||||
import type { Car } from "@/src/types/car";
|
import type { Car } from "@/src/types/car";
|
||||||
|
|
||||||
@@ -19,10 +19,22 @@ const thStyle: React.CSSProperties = {
|
|||||||
// ── icon button ──────────────────────────────────────────────────────────────
|
// ── icon 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
|
||||||
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onClick={e => { e.stopPropagation(); onClick(); }}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, padding: 0,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
background: bg,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,10 +132,16 @@ export function ArchivedCarTable({ cars, loading, search, page, pages, onView, o
|
|||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||||
].map(btn => (
|
].map(btn => (
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
<Button
|
||||||
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)" }}>
|
key={btn.label}
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={btn.action}
|
||||||
|
disabled={btn.disabled}
|
||||||
|
>
|
||||||
{btn.label}
|
{btn.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import { Alert, Spinner } from "../UI";
|
import { Alert, Button, Input, Modal, Textarea } from "../UI";
|
||||||
import { createRoleSchema, updateRoleSchema, type RoleFormErrors } from "@/src/validations/role.validator";
|
import { createRoleSchema, updateRoleSchema, type RoleFormErrors } from "@/src/validations/role.validator";
|
||||||
import { Permission, Role, RoleFormData } from "@/src/types/role";
|
import { Permission, Role, RoleFormData } from "@/src/types/role";
|
||||||
|
|
||||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
|
||||||
const S = {
|
|
||||||
input: {
|
|
||||||
width: "100%", height: 40, padding: "0 0.75rem",
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, color: "var(--color-text-primary)",
|
|
||||||
outline: "none", fontFamily: "var(--font-sans)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
label: {
|
|
||||||
display: "flex", flexDirection: "column" as const,
|
|
||||||
gap: 6, fontSize: 12, fontWeight: 600,
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
} as React.CSSProperties,
|
|
||||||
errorText: { fontSize: 11, color: "var(--color-danger)", fontWeight: 500 } as React.CSSProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Validation ────────────────────────────────────────────────────────────────
|
// ── Validation ────────────────────────────────────────────────────────────────
|
||||||
async function validate(data: RoleFormData, isNew: boolean): Promise<RoleFormErrors> {
|
async function validate(data: RoleFormData, isNew: boolean): Promise<RoleFormErrors> {
|
||||||
const schema = isNew ? createRoleSchema : updateRoleSchema;
|
const schema = isNew ? createRoleSchema : updateRoleSchema;
|
||||||
@@ -108,9 +90,7 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
|||||||
const [errors, setErrors] = useState<RoleFormErrors>({});
|
const [errors, setErrors] = useState<RoleFormErrors>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => { firstInputRef.current?.focus(); }, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
@@ -144,136 +124,92 @@ export function RoleFormModal({ editRole, permissions, onClose, onSubmit }: Role
|
|||||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle = (field: keyof RoleFormErrors): React.CSSProperties => ({
|
|
||||||
...S.input,
|
|
||||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="role-modal-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={isNew ? "دور جديد" : editRole?.name ?? ""}
|
||||||
style={{
|
subtitle={isNew ? "إضافة دور" : "تعديل دور"}
|
||||||
position: "fixed", inset: 0, zIndex: 50,
|
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} disabled={saving}>
|
||||||
|
إلغاء
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="role-form" loading={saving}>
|
||||||
|
{isNew ? "إنشاء الدور" : "حفظ التغييرات"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<form
|
||||||
onClick={e => e.stopPropagation()}
|
id="role-form"
|
||||||
style={{
|
onSubmit={handleSubmit}
|
||||||
width: "100%", maxWidth: 600,
|
noValidate
|
||||||
background: "var(--color-surface)",
|
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
|
||||||
overflow: "hidden", display: "flex", flexDirection: "column",
|
|
||||||
maxHeight: "90vh",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
||||||
<div style={{
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
<div dir="rtl">
|
||||||
padding: "1.25rem 1.5rem",
|
<Input
|
||||||
borderBottom: "1px solid var(--color-border)",
|
label="اسم الدور *"
|
||||||
background: "var(--color-surface-muted)",
|
value={form.name}
|
||||||
flexShrink: 0,
|
onChange={set("name")}
|
||||||
}}>
|
placeholder="مثال: مدير الفروع"
|
||||||
<div>
|
error={errors.name}
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
dir="rtl"
|
||||||
{isNew ? "إضافة دور" : "تعديل دور"}
|
autoFocus
|
||||||
</p>
|
/>
|
||||||
<h2 id="role-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
{isNew ? "دور جديد" : editRole?.name}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
|
||||||
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
<div dir="rtl">
|
||||||
<form
|
<Textarea
|
||||||
onSubmit={handleSubmit} noValidate
|
label="الوصف"
|
||||||
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", overflowY: "auto", flex: 1 }}
|
value={form.description}
|
||||||
>
|
onChange={set("description")}
|
||||||
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
|
placeholder="وصف مختصر لمهام هذا الدور…"
|
||||||
|
error={errors.description}
|
||||||
|
dir="rtl"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Permissions */}
|
||||||
<label style={S.label} dir="rtl">
|
{permissions.length > 0 && (
|
||||||
اسم الدور *
|
<div dir="rtl">
|
||||||
<input ref={firstInputRef} style={inputStyle("name")} value={form.name} onChange={set("name")} placeholder="مثال: مدير الفروع" dir="rtl" />
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
|
||||||
</label>
|
الصلاحيات
|
||||||
|
</span>
|
||||||
{/* Description */}
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<label style={S.label} dir="rtl">
|
<Button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: permissions.map(x => x.id) }))}
|
||||||
الوصف
|
style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
||||||
<textarea
|
تحديد الكل
|
||||||
style={{
|
</Button>
|
||||||
...S.input, height: "auto", padding: "0.5rem 0.75rem", resize: "none",
|
<Button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: [] }))}
|
||||||
...(errors.description ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
||||||
} as React.CSSProperties}
|
إلغاء الكل
|
||||||
rows={3}
|
</Button>
|
||||||
value={form.description}
|
|
||||||
onChange={set("description")}
|
|
||||||
placeholder="وصف مختصر لمهام هذا الدور…"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
{errors.description && <span style={S.errorText}>{errors.description}</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Permissions */}
|
|
||||||
{permissions.length > 0 && (
|
|
||||||
<div dir="rtl">
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
|
|
||||||
الصلاحيات
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
<button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: permissions.map(x => x.id) }))}
|
|
||||||
style={{ fontSize: 11, fontWeight: 600, color: "#2563EB", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
|
||||||
تحديد الكل
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setForm(p => ({ ...p, permissionIds: [] }))}
|
|
||||||
style={{ fontSize: 11, fontWeight: 600, color: "#DC2626", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
|
||||||
إلغاء الكل
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
|
||||||
border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)",
|
|
||||||
padding: "0.875rem", background: "var(--color-surface-muted)",
|
|
||||||
maxHeight: 280, overflowY: "auto",
|
|
||||||
}}>
|
|
||||||
{Object.entries(grouped).map(([module, perms]) => (
|
|
||||||
<PermissionGroup
|
|
||||||
key={module} module={module} perms={perms}
|
|
||||||
selected={form.permissionIds} onToggle={togglePerm}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 6 }}>
|
|
||||||
{form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div style={{
|
||||||
|
border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)",
|
||||||
{/* Actions */}
|
padding: "0.875rem", background: "var(--color-surface-muted)",
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem", flexShrink: 0 }}>
|
maxHeight: 280, overflowY: "auto",
|
||||||
<button type="button" onClick={onClose} disabled={saving}
|
}}>
|
||||||
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
{Object.entries(grouped).map(([module, perms]) => (
|
||||||
إلغاء
|
<PermissionGroup
|
||||||
</button>
|
key={module} module={module} perms={perms}
|
||||||
<button type="submit" disabled={saving}
|
selected={form.permissionIds} onToggle={togglePerm}
|
||||||
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
/>
|
||||||
{saving && <Spinner size="sm" className="text-white" />}
|
))}
|
||||||
{saving ? "جارٍ الحفظ…" : isNew ? "إنشاء الدور" : "حفظ التغييرات"}
|
</div>
|
||||||
</button>
|
<p style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: 6 }}>
|
||||||
|
{form.permissionIds.length} صلاحية محددة من أصل {permissions.length}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedRoleTable } from "./ArchivedRoleTable";
|
import { ArchivedRoleTable } from "./ArchivedRoleTable";
|
||||||
import { ArchivedRoleDetailModal } from "./ArchivedRoleDetailModal";
|
import { ArchivedRoleDetailModal } from "./ArchivedRoleDetailModal";
|
||||||
import { useArchivedRoles } from "@/src/hooks/archive/useArchiveRole";
|
import { useArchivedRoles } from "@/src/hooks/archive/useArchiveRole";
|
||||||
@@ -10,6 +10,21 @@ interface ArchivedRolesModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
|
export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
|
||||||
const [viewRoleId, setViewRoleId] = useState<string | null>(null);
|
const [viewRoleId, setViewRoleId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -20,79 +35,28 @@ export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
|
|||||||
} = useArchivedRoles();
|
} = useArchivedRoles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
role="dialog" aria-modal="true" aria-labelledby="role-archive-modal-title"
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewRoleId && (
|
{viewRoleId && (
|
||||||
<ArchivedRoleDetailModal roleId={viewRoleId} onClose={() => setViewRoleId(null)} />
|
<ArchivedRoleDetailModal roleId={viewRoleId} onClose={() => setViewRoleId(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<Modal
|
||||||
onClick={e => e.stopPropagation()}
|
open
|
||||||
style={{
|
onClose={onClose}
|
||||||
width: "100%", maxWidth: 920,
|
size="lg"
|
||||||
background: "var(--color-surface-sunken)",
|
subtitle="الأرشيف"
|
||||||
borderRadius: "var(--radius-xl)",
|
title={`الأدوار المؤرشفة (${total})`}
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* header */}
|
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
<div dir="rtl" style={{
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
||||||
padding: "1.25rem 1.5rem",
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
|
|
||||||
الأرشيف
|
|
||||||
</p>
|
|
||||||
<h2 id="role-archive-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
|
||||||
الأدوار المؤرشفة
|
|
||||||
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
|
|
||||||
({total})
|
|
||||||
</span>
|
|
||||||
</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", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
||||||
{/* search — client-side, since /role/archived has no search param */}
|
{/* search — client-side, since /role/archived has no search param */}
|
||||||
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<Input
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
label="بحث"
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="بحث باسم الدور..."
|
placeholder="بحث باسم الدور..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
icon={searchIcon}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
|
||||||
width: "100%", height: 40,
|
|
||||||
paddingRight: 36, paddingLeft: 12,
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,7 +73,7 @@ export function ArchivedRolesModal({ onClose }: ArchivedRolesModalProps) {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user