add archive endpoint to user , driver , car ,order ,trip and update trip fails also update saidecar

This commit is contained in:
m7amedez5511
2026-07-02 19:04:51 +03:00
parent c33778012a
commit d225291b70
29 changed files with 2096 additions and 432 deletions

View File

@@ -0,0 +1,278 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Spinner } from "../UI";
import { CarMaintenanceFormModal } from "./CarMaintananceFormModal";
import { CarMaintenanceDeleteModal } from "./CarMaintananceDeleteModal";
import {
useCarMaintenanceList,
useCarMaintenanceMutations,
useMaintenanceToast,
} from "@/src/hooks/UseCarsMaintanance";
import {
MAINTENANCE_STATUS_MAP,
fmtDate,
fmtCost,
durationDays,
getMaintenanceStatus,
} from "@/src/types/carMaintanance";
import type {
CarMaintenance,
CreateMaintenancePayload,
UpdateMaintenancePayload,
} from "@/src/types/carMaintanance";
// ── Toast ─────────────────────────────────────────────────────────────────────
// Small floating message that confirms an action worked (or explains why it didn't).
function MaintenanceToast({ notification }: { notification: { type: "success" | "error"; message: string } | null }) {
if (!notification) return null;
const ok = notification.type === "success";
return (
<div role="status" aria-live="polite" style={{
position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)",
zIndex: 9999, pointerEvents: "none",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: ok ? "#065F46" : "#7F1D1D",
color: "#FFF", fontSize: 13, fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,.25)",
maxWidth: "90vw", whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}>
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
<span>{notification.message}</span>
</div>
</div>
);
}
// ── Record card ───────────────────────────────────────────────────────────────
function MaintenanceCard({
record,
onEdit,
onDelete,
}: {
record: CarMaintenance;
onEdit: () => void;
onDelete: () => void;
}) {
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
return (
<div style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
padding: "1rem",
display: "flex", flexDirection: "column", gap: "0.6rem",
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<p style={{ fontSize: 14, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
{record.reason}
</p>
<span style={{
borderRadius: "var(--radius-full)",
border: `1px solid ${status.border}`,
background: status.bg,
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: status.color,
whiteSpace: "nowrap", flexShrink: 0,
}}>
{status.label}
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.4rem", fontSize: 12 }}>
<div>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التكلفة</span>
<span style={{ color: "var(--color-text-primary)" }}>{fmtCost(record.cost)}</span>
</div>
<div>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>المدة</span>
<span style={{ color: "var(--color-text-primary)" }}>{durationDays(record.startAt, record.endAt)} يوم</span>
</div>
<div>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>تاريخ البدء</span>
<span style={{ color: "var(--color-text-primary)" }}>{fmtDate(record.startAt)}</span>
</div>
<div>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>تاريخ الانتهاء</span>
<span style={{ color: "var(--color-text-primary)" }}>{fmtDate(record.endAt)}</span>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", paddingTop: "0.25rem" }}>
<button type="button" onClick={onEdit}
style={{ flex: 1, height: 32, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
تعديل
</button>
<button type="button" onClick={onDelete}
style={{ height: 32, padding: "0 0.875rem", borderRadius: "var(--radius-md)", border: "1px solid #FECACA", background: "#FEF2F2", fontSize: 12, fontWeight: 600, color: "#DC2626", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
حذف
</button>
</div>
</div>
);
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface CarMaintenanceDetailPanelProps {
carId: string;
/** e.g. "تويوتا لاند كروزر — أ ب ج 1234", shown in the header. */
carLabel?: string;
onClose: () => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CarMaintenanceDetailPanel({ carId, carLabel, onClose }: CarMaintenanceDetailPanelProps) {
const { records, loading, error, loadRecords, removeRecord } = useCarMaintenanceList(carId);
const { toast, notify } = useMaintenanceToast();
const [formTarget, setFormTarget] = useState<CarMaintenance | null | false>(false); // false = closed
const [deleteTarget, setDeleteTarget] = useState<CarMaintenance | null>(null);
const getEditTarget = useCallback(
() => (formTarget instanceof Object && formTarget !== null ? (formTarget as CarMaintenance) : null),
[formTarget],
);
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMaintenanceMutations({
carId,
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadRecords(); },
onError: (msg) => notify({ type: "error", message: msg }),
onDeleted: (id) => { removeRecord(id); setDeleteTarget(null); },
getEditTarget,
});
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape" && formTarget === false && !deleteTarget) onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [formTarget, deleteTarget, onClose]);
const totalCost = records.reduce((sum, r) => sum + (r.cost ?? 0), 0);
return (
<>
<MaintenanceToast notification={toast} />
{/* Backdrop */}
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 40, background: "rgba(15,23,42,0.45)", backdropFilter: "blur(2px)" }} />
{/* Panel */}
<aside
aria-label="سجل صيانة المركبة"
style={{
position: "fixed", top: 0, left: 0, bottom: 0, zIndex: 50,
width: "min(480px, 100vw)",
background: "var(--color-surface)",
borderRight: "1px solid var(--color-border)",
display: "flex", flexDirection: "column",
boxShadow: "8px 0 40px rgba(0,0,0,.18)",
}}
>
{/* Header */}
<div style={{ padding: "1.25rem 1.5rem", borderBottom: "1px solid var(--color-border)", background: "var(--color-surface-muted)", flexShrink: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
سجل الصيانة
</p>
<h2 style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{carLabel ?? "المركبة"}
</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", flexShrink: 0 }}>
×
</button>
</div>
{!loading && !error && records.length > 0 && (
<p style={{ marginTop: 10, fontSize: 12, color: "var(--color-text-muted)" }}>
{records.length} سجل · إجمالي التكلفة {fmtCost(totalCost)}
</p>
)}
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }} dir="rtl">
{loading && (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ التحميل</span>
</div>
)}
{error && (
<div style={{ borderRadius: "var(--radius-md)", background: "#FEF2F2", border: "1px solid #FECACA", padding: "0.75rem 1rem", fontSize: 13, color: "#DC2626" }}>
{error}
</div>
)}
{!loading && !error && records.length === 0 && (
<div style={{ textAlign: "center", padding: "3rem 0" }}>
<div style={{ fontSize: 40, marginBottom: 10 }}>🔧</div>
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)" }}>لا توجد سجلات صيانة بعد</p>
<p style={{ fontSize: 12, color: "var(--color-text-hint)", marginTop: 4 }}>
اضغط على &quot;إضافة سجل&quot; لتسجيل أول عملية صيانة لهذه المركبة.
</p>
</div>
)}
{!loading && !error && records.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{records.map((record) => (
<MaintenanceCard
key={record.id}
record={record}
onEdit={() => setFormTarget(record)}
onDelete={() => setDeleteTarget(record)}
/>
))}
</div>
)}
</div>
{/* Footer actions */}
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", flexShrink: 0 }}>
<button type="button" onClick={() => setFormTarget(null)}
style={{ width: "100%", height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
إضافة سجل صيانة
</button>
</div>
</aside>
{/* Nested modals */}
{formTarget !== false && (
<CarMaintenanceFormModal
editRecord={formTarget}
carLabel={carLabel}
onClose={() => setFormTarget(false)}
onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) =>
handleFormSubmit(payload, isNew)
}
/>
)}
{deleteTarget && (
<CarMaintenanceDeleteModal
record={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => handleDeleteConfirm(deleteTarget)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as yup from "yup";
import { Alert, Spinner } from "../UI";
import {
createMaintenanceSchema,
updateMaintenanceSchema,
} from "@/src/validations/carMaintanance.validator";
import type {
CarMaintenance,
CreateMaintenancePayload,
MaintenanceFormErrors,
UpdateMaintenancePayload,
} from "@/src/types/carMaintanance";
// ── Shared input style ────────────────────────────────────────────────────────
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,
};
// ── yup validation ────────────────────────────────────────────────────────────
// Checks the form values against the right schema and turns any problems
// into a simple field -> message map the inputs below can read from.
async function validate(
form: Partial<CreateMaintenancePayload>,
isNew: boolean,
): Promise<MaintenanceFormErrors> {
const schema = isNew ? createMaintenanceSchema : updateMaintenanceSchema;
try {
await schema.validate(form, { abortEarly: false });
return {};
} catch (err) {
if (err instanceof yup.ValidationError) {
return err.inner.reduce<MaintenanceFormErrors>((acc, e) => {
const field = e.path as keyof MaintenanceFormErrors;
if (field && !acc[field]) acc[field] = e.message;
return acc;
}, {});
}
return {};
}
}
// ── Date helper ───────────────────────────────────────────────────────────────
// <input type="date"> gives "YYYY-MM-DD"; the backend expects full ISO-8601.
function toIsoDateTime(val: string): string {
if (!val) return val;
if (val.includes("T")) return val;
return `${val}T00:00:00.000Z`;
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface CarMaintenanceFormModalProps {
/** Pass null to create a brand new record, or an existing record to edit it. */
editRecord: CarMaintenance | null;
/** Shown in the header so the person knows which car this belongs to. */
carLabel?: string;
onClose: () => void;
onSubmit: (
payload: CreateMaintenancePayload | UpdateMaintenancePayload,
isNew: boolean,
) => Promise<boolean>;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CarMaintenanceFormModal({
editRecord,
carLabel,
onClose,
onSubmit,
}: CarMaintenanceFormModalProps) {
const isNew = editRecord === null;
// ── Form state ─────────────────────────────────────────────────────────────
const [reason, setReason] = useState(editRecord?.reason ?? "");
const [cost, setCost] = useState<number | undefined>(editRecord?.cost ?? undefined);
const [startAt, setStartAt] = useState(editRecord?.startAt?.slice(0, 10) ?? "");
const [endAt, setEndAt] = useState(editRecord?.endAt?.slice(0, 10) ?? "");
const [errors, setErrors] = useState<MaintenanceFormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState("");
const firstRef = useRef<HTMLInputElement>(null);
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 =>
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) =>
setErrors((p) => ({ ...p, [field]: undefined }));
// ── Submit ────────────────────────────────────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Build a snapshot for yup — include everything so optional rules also run.
const formSnapshot: Partial<CreateMaintenancePayload> = {
reason,
cost,
...(startAt && { startAt }),
...(endAt && { endAt }),
};
const errs = await validate(formSnapshot, isNew);
if (Object.keys(errs).length) {
setErrors(errs);
return;
}
// Build the final payload — dates go out as full ISO-8601 strings.
const raw: Record<string, unknown> = { reason, cost };
if (startAt) raw.startAt = toIsoDateTime(startAt);
if (endAt) raw.endAt = toIsoDateTime(endAt);
const payload = raw as unknown as CreateMaintenancePayload;
setSaving(true);
setApiError("");
const ok = await onSubmit(payload, isNew);
setSaving(false);
if (ok) onClose();
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="maintenance-modal-title"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center",
padding: "1rem", overflowY: "auto",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%", maxWidth: 520,
background: "var(--color-surface)",
borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
margin: "auto",
}}
>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
{isNew ? "إضافة سجل صيانة" : "تعديل سجل صيانة"}
</p>
<h2 id="maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{carLabel ?? (isNew ? "سجل صيانة جديد" : "تعديل السجل")}
</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>
{/* Form */}
<form
onSubmit={handleSubmit}
noValidate
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem", maxHeight: "75vh", overflowY: "auto" }}
dir="rtl"
>
{apiError && <Alert type="error" message={apiError} onClose={() => setApiError("")} />}
<label style={labelStyle}>
سبب الصيانة *
<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>
);
}

View File

View File

@@ -22,6 +22,14 @@ function fmtDate(iso?: string | null): string {
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" });
}
function fmtAmount(n?: string | number | null): string {
if (n == null) return "—";
const num = typeof n === "string" ? parseFloat(n) : n;
if (Number.isNaN(num)) return "—";
return `${num.toFixed(2)} ر.س`;
}
export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetailModalProps) {
const s = ORDER_STATUS_MAP[order.currentStatus] ?? ORDER_STATUS_MAP.Created;
@@ -53,7 +61,7 @@ export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetail
<DetailRow label="اسم المستلم" value={order.recipientName} />
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
<DetailRow label="الكمية" value={String(order.quantity)} />
<DetailRow label="الإجمالي الفرعي" value={order.subTotal != null ? `${order.subTotal.toFixed(2)} ر.س` : "—"} mono />
<DetailRow label="الإجمالي الفرعي" value={fmtAmount(order.subTotal)} mono />
<DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />

View File

@@ -4,9 +4,12 @@ import { Spinner } from "../../UI";
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
import type { ArchivedOrder } from "@/src/types/order";
function fmtAmount(n?: number | null): string {
function fmtAmount(n?: string | number | null): string {
if (n == null) return "—";
return `${n.toFixed(2)} ر.س`;
const num = typeof n === "string" ? parseFloat(n) : n;
if (Number.isNaN(num)) return "—";
return `${num.toFixed(2)} ر.س`;
}
const cardStyle: React.CSSProperties = {