update saidebar layout build order crud
This commit is contained in:
104
src/Components/Order/OrderDeleteModal.tsx
Normal file
104
src/Components/Order/OrderDeleteModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Order } from "@/src/types/order";
|
||||
|
||||
interface OrderDeleteModalProps {
|
||||
order: Order;
|
||||
deleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors DriverDeleteModal.tsx 1:1 — same alertdialog role, same
|
||||
* backdrop/click-outside-to-close behavior, same Escape-to-close effect,
|
||||
* same button layout. Only the copy and the identifying fields differ.
|
||||
*/
|
||||
export function OrderDeleteModal({ order, deleting, onCancel, onConfirm }: OrderDeleteModalProps) {
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog" aria-modal="true" aria-labelledby="order-del-title"
|
||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 60,
|
||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%", maxWidth: 420,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid #FECACA",
|
||||
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||
padding: "2rem",
|
||||
display: "flex", flexDirection: "column", gap: "1rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div style={{
|
||||
width: 52, height: 52, margin: "0 auto", borderRadius: "50%",
|
||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 id="order-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
حذف الطلب
|
||||
</h2>
|
||||
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||
هل أنت متأكد من حذف الطلب{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>{order.shipmentNumber}</strong>
|
||||
{" "}الخاص بـ{" "}
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{order.recipientName}
|
||||
</span>
|
||||
؟ لا يمكن التراجع عن هذا الإجراء.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button type="button" onClick={onCancel} disabled={deleting}
|
||||
style={{
|
||||
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)", background: "var(--color-surface)",
|
||||
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
||||
cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)",
|
||||
}}>
|
||||
إلغاء
|
||||
</button>
|
||||
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||
style={{
|
||||
flex: 1, height: 40, borderRadius: "var(--radius-md)",
|
||||
border: "none", background: "#DC2626",
|
||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||
cursor: deleting ? "not-allowed" : "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1,
|
||||
}}>
|
||||
{deleting && <Spinner size="sm" className="text-white" />}
|
||||
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
590
src/Components/Order/OrderDetailBanel.tsx
Normal file
590
src/Components/Order/OrderDetailBanel.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Spinner, Alert } from "../UI";
|
||||
import { orderService } from "@/src/services/order.service";
|
||||
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
||||
|
||||
// ── Status config ────────────────────────────────────────────────────────
|
||||
// Same shape as DRIVER_STATUS_MAP in src/types/driver.ts, kept local here
|
||||
// since order.ts doesn't currently export a colour map of its own.
|
||||
const ORDER_STATUS_MAP: Record<
|
||||
OrderStatus,
|
||||
{ label: string; color: string; bg: string; border: string; dot: string }
|
||||
> = {
|
||||
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
|
||||
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE", dot: "#8B5CF6" },
|
||||
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
||||
Delivered: { label: "تم التسليم", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
|
||||
Returned: { label: "مُرتجع", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" },
|
||||
Cancelled: { label: "ملغي", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0", dot: "#94A3B8" },
|
||||
};
|
||||
|
||||
const PAY_STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
Pending: { label: "معلَّق", color: "#D97706" },
|
||||
Paid: { label: "مدفوع", color: "#16A34A" },
|
||||
Failed: { label: "فشل", color: "#DC2626" },
|
||||
Refunded: { label: "مُسترجع", color: "#64748B" },
|
||||
};
|
||||
|
||||
const PAY_METHOD_LABEL: Record<string, string> = {
|
||||
Cash: "نقداً",
|
||||
Card: "بطاقة",
|
||||
Prepaid: "مدفوع مسبقاً",
|
||||
};
|
||||
|
||||
// ── Helpers — identical to DriverDetailPanel.tsx's fmtDate ──────────────
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function fmtAmount(n?: number | string | null): string {
|
||||
if (n == null || n === "") return "—";
|
||||
const num = Number(n);
|
||||
if (isNaN(num)) return "—";
|
||||
return `${num.toFixed(2)} ر.س`;
|
||||
}
|
||||
|
||||
// ── Sub-components — copied verbatim from DriverDetailPanel.tsx ─────────
|
||||
// (DetailRow / SectionHeading carry no Driver-specific logic, so they are
|
||||
// reproduced here rather than imported, matching how the Driver files keep
|
||||
// their detail-panel building blocks local to the panel that uses them.)
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
warn = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
padding: "0.55rem 0",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
color: warn ? "#D97706" : "var(--color-text-primary)",
|
||||
fontWeight: warn ? 600 : 400,
|
||||
maxWidth: "60%",
|
||||
textAlign: "left",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{warn && value !== "—" ? "⚠ " : ""}
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ title }: { title: string }) {
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.25em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-hint)",
|
||||
fontWeight: 700,
|
||||
margin: "1.25rem 0 0.5rem",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface OrderDetailPanelProps {
|
||||
orderId: string;
|
||||
onClose: () => void;
|
||||
onEdit: (order: Order) => void;
|
||||
onDelete: (order: Order) => void;
|
||||
/** Bubble a status change up so the list row updates without a full reload */
|
||||
onStatusChanged?: (order: Order) => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
// Structurally identical to DriverDetailPanel.tsx: fixed backdrop + slide-in
|
||||
// <aside>, header with avatar-equivalent badge, scrollable body of
|
||||
// SectionHeading/DetailRow blocks, sticky footer actions.
|
||||
|
||||
export function OrderDetailPanel({
|
||||
orderId,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChanged,
|
||||
}: OrderDetailPanelProps) {
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Inline status-update control state
|
||||
const [statusDraft, setStatusDraft] = useState<OrderStatus | "">("");
|
||||
const [statusReason, setStatusReason] = useState("");
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
|
||||
const loadOrder = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await orderService.getById(orderId);
|
||||
const data = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
||||
setOrder(data);
|
||||
setStatusDraft(data.currentStatus);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الطلب.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => { loadOrder(); }, [loadOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
// ── Status update — alert shown inline in the panel, success bubbled up ──
|
||||
const handleStatusUpdate = useCallback(async () => {
|
||||
if (!order || !statusDraft || statusDraft === order.currentStatus) return;
|
||||
setUpdatingStatus(true);
|
||||
setStatusError(null);
|
||||
try {
|
||||
const payload: UpdateOrderStatusPayload = { status: statusDraft };
|
||||
if (statusReason) payload.reason = statusReason;
|
||||
const res = await orderService.updateStatus(order.id, payload);
|
||||
const updated = (res as unknown as { data: Order }).data ?? (res as unknown as Order);
|
||||
setOrder(updated);
|
||||
setStatusReason("");
|
||||
onStatusChanged?.(updated);
|
||||
} catch (err: unknown) {
|
||||
setStatusError(err instanceof Error ? err.message : "تعذّر تحديث حالة الطلب.");
|
||||
} finally {
|
||||
setUpdatingStatus(false);
|
||||
}
|
||||
}, [order, statusDraft, statusReason, onStatusChanged]);
|
||||
|
||||
const statusConfig = order ? ORDER_STATUS_MAP[order.currentStatus] : null;
|
||||
const payStatus = order?.paymentStatus ? PAY_STATUS_MAP[order.paymentStatus] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 40,
|
||||
background: "rgba(15,23,42,0.45)",
|
||||
backdropFilter: "blur(2px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Slide-in panel */}
|
||||
<aside
|
||||
aria-label="تفاصيل الطلب"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
zIndex: 50,
|
||||
width: "min(520px, 100vw)",
|
||||
background: "var(--color-surface)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "8px 0 40px rgba(0,0,0,.18)",
|
||||
overflowY: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* ── 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", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Shipment "avatar" badge — order has no photo, so a glyph stands in for it,
|
||||
matching the circular-badge slot Driver uses for its avatar image. */}
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
border: "2px solid var(--color-brand-200)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-brand-600)" strokeWidth="2">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||
ملف الطلب
|
||||
</p>
|
||||
{order && (
|
||||
<h2 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "3px 0 0", fontFamily: "var(--font-mono)" }}>
|
||||
{order.shipmentNumber}
|
||||
</h2>
|
||||
)}
|
||||
{order?.trip?.tripNumber && (
|
||||
<p style={{ fontSize: 11, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)", margin: "2px 0 0" }}>
|
||||
رحلة: {order.trip.tripNumber}
|
||||
</p>
|
||||
)}
|
||||
</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",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable content ── */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{order && !loading && (
|
||||
<>
|
||||
{/* Status badges */}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||
{statusConfig && (
|
||||
<span style={{
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${statusConfig.border}`,
|
||||
background: statusConfig.bg,
|
||||
padding: "0.3rem 0.875rem",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: statusConfig.color,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: statusConfig.dot }} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
)}
|
||||
{payStatus && (
|
||||
<span style={{
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
padding: "0.3rem 0.875rem",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: payStatus.color,
|
||||
}}>
|
||||
{payStatus.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeading title="بيانات المستلم" />
|
||||
<DetailRow label="اسم المستلم" value={order.recipientName} />
|
||||
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
|
||||
<DetailRow label="العميل" value={order.client?.name ?? "—"} />
|
||||
|
||||
<SectionHeading title="بيانات الشحنة" />
|
||||
<DetailRow label="رقم الشحنة" value={order.shipmentNumber} mono />
|
||||
<DetailRow label="نوع الشحنة" value={order.type ?? "—"} />
|
||||
<DetailRow label="الكمية" value={String(order.quantity ?? "—")} />
|
||||
<DetailRow label="الوزن" value={order.weight != null ? `${order.weight} كجم` : "—"} />
|
||||
<DetailRow label="الرحلة" value={order.trip?.tripNumber ?? "—"} mono />
|
||||
|
||||
<SectionHeading title="بيانات الدفع" />
|
||||
<DetailRow label="طريقة الدفع" value={order.paymentMethod ? PAY_METHOD_LABEL[order.paymentMethod] ?? order.paymentMethod : "—"} />
|
||||
<DetailRow label="حالة الدفع" value={payStatus?.label ?? "—"} />
|
||||
<DetailRow label="الإجمالي الفرعي" value={fmtAmount(order.subTotal)} mono />
|
||||
<DetailRow label="ضريبة القيمة المضافة" value={fmtAmount(order.vatAmount)} mono />
|
||||
<DetailRow label="الإجمالي" value={fmtAmount(order.totalPrice)} mono />
|
||||
|
||||
{/* ── Delivery Address ── */}
|
||||
{order.deliveryAddress?.details && Object.values(order.deliveryAddress.details).some(Boolean) && (
|
||||
<>
|
||||
<SectionHeading title="عنوان التسليم" />
|
||||
{order.deliveryAddress.details.city && <DetailRow label="المدينة" value={order.deliveryAddress.details.city} />}
|
||||
{order.deliveryAddress.details.district && <DetailRow label="الحي" value={order.deliveryAddress.details.district} />}
|
||||
{order.deliveryAddress.details.street && <DetailRow label="الشارع" value={order.deliveryAddress.details.street} />}
|
||||
{order.deliveryAddress.details.buildingNo && <DetailRow label="رقم المبنى" value={order.deliveryAddress.details.buildingNo} mono />}
|
||||
{order.deliveryAddress.details.unitNo && <DetailRow label="رقم الوحدة" value={order.deliveryAddress.details.unitNo} mono />}
|
||||
{order.deliveryAddress.details.zipCode && <DetailRow label="الرمز البريدي" value={order.deliveryAddress.details.zipCode} mono />}
|
||||
</>
|
||||
)}
|
||||
{!order.deliveryAddress?.details && order.deliveryAddressId && (
|
||||
<>
|
||||
<SectionHeading title="عنوان التسليم" />
|
||||
<DetailRow label="معرّف العنوان" value={order.deliveryAddressId} mono />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Pickup Address ── */}
|
||||
{order.pickupAddress?.details && Object.values(order.pickupAddress.details).some(Boolean) && (
|
||||
<>
|
||||
<SectionHeading title="عنوان الاستلام" />
|
||||
{order.pickupAddress.details.city && <DetailRow label="المدينة" value={order.pickupAddress.details.city} />}
|
||||
{order.pickupAddress.details.district && <DetailRow label="الحي" value={order.pickupAddress.details.district} />}
|
||||
{order.pickupAddress.details.street && <DetailRow label="الشارع" value={order.pickupAddress.details.street} />}
|
||||
{order.pickupAddress.details.buildingNo && <DetailRow label="رقم المبنى" value={order.pickupAddress.details.buildingNo} mono />}
|
||||
{order.pickupAddress.details.unitNo && <DetailRow label="رقم الوحدة" value={order.pickupAddress.details.unitNo} mono />}
|
||||
{order.pickupAddress.details.zipCode && <DetailRow label="الرمز البريدي" value={order.pickupAddress.details.zipCode} mono />}
|
||||
</>
|
||||
)}
|
||||
{!order.pickupAddress?.details && order.pickupAddressId && (
|
||||
<>
|
||||
<SectionHeading title="عنوان الاستلام" />
|
||||
<DetailRow label="معرّف العنوان" value={order.pickupAddressId} mono />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionHeading title="معلومات النظام" /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
|
||||
|
||||
{/* ── Inline status update ──
|
||||
Same alert/feedback pattern as the rest of the panel: a
|
||||
local Alert renders any failure, success simply updates
|
||||
the badge above (no extra toast — the parent page's
|
||||
global toast already fires via onStatusChanged). */}
|
||||
<SectionHeading title="تحديث الحالة" />
|
||||
{statusError && (
|
||||
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||
<select
|
||||
value={statusDraft}
|
||||
onChange={(e) => setStatusDraft(e.target.value as OrderStatus)}
|
||||
dir="rtl"
|
||||
style={{
|
||||
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)",
|
||||
fontFamily: "var(--font-sans)", cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={statusReason}
|
||||
onChange={(e) => setStatusReason(e.target.value)}
|
||||
placeholder="سبب التغيير (اختياري)"
|
||||
dir="rtl"
|
||||
style={{
|
||||
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)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStatusUpdate}
|
||||
disabled={updatingStatus || !statusDraft || statusDraft === order.currentStatus}
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: updatingStatus ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||
cursor: (updatingStatus || !statusDraft || statusDraft === order.currentStatus) ? "not-allowed" : "pointer",
|
||||
opacity: (!statusDraft || statusDraft === order.currentStatus) ? 0.6 : 1,
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{updatingStatus && <Spinner size="sm" className="text-white" />}
|
||||
{updatingStatus ? "جارٍ التحديث…" : "تحديث الحالة"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{order.statusHistory && order.statusHistory.length > 0 && (
|
||||
<>
|
||||
<SectionHeading title="سجل الحالات" />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{order.statusHistory.slice(0, 5).map((h) => {
|
||||
const s = ORDER_STATUS_MAP[h.status] ?? ORDER_STATUS_MAP.Created;
|
||||
return (
|
||||
<div
|
||||
key={h.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${s.border}`,
|
||||
background: s.bg,
|
||||
padding: "0.5rem 0.875rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
|
||||
{h.reason && (
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
|
||||
— {h.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{fmtDate(h.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer actions ── */}
|
||||
{order && (
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.5rem",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(order)}
|
||||
style={{
|
||||
height: 40, padding: "0 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
fontSize: 13, fontWeight: 700, color: "#DC2626",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(order)}
|
||||
style={{
|
||||
height: 40, padding: "0 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-brand-200)",
|
||||
background: "var(--color-brand-50, #EFF6FF)",
|
||||
fontSize: 13, fontWeight: 700, color: "var(--color-brand-600)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1, height: 40,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
إغلاق
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { ORDER_STATUS_MAP, PAY_STATUS_MAP, PAY_METHOD_LABEL };
|
||||
929
src/Components/Order/OrderFormModal.tsx
Normal file
929
src/Components/Order/OrderFormModal.tsx
Normal file
@@ -0,0 +1,929 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { clientService } from "@/src/services/client.service";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { createOrderSchema, updateOrderSchema } from "@/src/validations/order.validation";
|
||||
import type { ValidationError } from "yup";
|
||||
import type {
|
||||
Order,
|
||||
CreateOrderPayload,
|
||||
UpdateOrderPayload,
|
||||
PaymentMethod,
|
||||
PaymentStatus,
|
||||
} from "@/src/types/order";
|
||||
import type { Client } from "@/src/types/client";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
import type { OrderSchemaErrors } from "@/src/validations/order.validation";
|
||||
|
||||
// ── Shared input / label styles — verbatim from DriverFormModal.tsx ──────────
|
||||
|
||||
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 = {
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.25em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-hint)",
|
||||
fontWeight: 700,
|
||||
margin: "0.5rem 0 0",
|
||||
};
|
||||
|
||||
const optionalLabelStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-hint)",
|
||||
marginRight: 4,
|
||||
};
|
||||
|
||||
/** Small "Create new" link rendered below each relation dropdown. */
|
||||
const createLinkStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
color: "var(--color-brand-600)",
|
||||
textDecoration: "none",
|
||||
fontWeight: 500,
|
||||
marginTop: 2,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface OrderFormModalProps {
|
||||
editOrder: Order | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
payload: CreateOrderPayload | UpdateOrderPayload,
|
||||
isNew: boolean,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalProps) {
|
||||
const isNew = editOrder === null;
|
||||
|
||||
// ── Relation data — clients & trips loaded once on mount ─────────────────
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [relLoading, setRelLoading] = useState(true);
|
||||
|
||||
// ── Form state ────────────────────────────────────────────────────────────
|
||||
const [shipmentNumber, setShipmentNumber] = useState(editOrder?.shipmentNumber ?? "");
|
||||
const [recipientName, setRecipientName] = useState(editOrder?.recipientName ?? "");
|
||||
const [recipientPhone, setRecipientPhone] = useState(editOrder?.recipientPhone ?? "");
|
||||
const [clientId, setClientId] = useState(editOrder?.clientId ?? "");
|
||||
const [tripId, setTripId] = useState(editOrder?.tripId ?? "");
|
||||
const [type, setType] = useState(editOrder?.type ?? "");
|
||||
const [quantity, setQuantity] = useState(String(editOrder?.quantity ?? 1));
|
||||
const [weight, setWeight] = useState(
|
||||
editOrder?.weight != null ? String(editOrder.weight) : "",
|
||||
);
|
||||
const [subTotal, setSubTotal] = useState(
|
||||
editOrder?.subTotal != null ? String(editOrder.subTotal) : "",
|
||||
);
|
||||
const [vatRate, setVatRate] = useState(
|
||||
editOrder?.vatRate != null ? String(editOrder.vatRate) : "",
|
||||
);
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | "">(
|
||||
editOrder?.paymentMethod ?? "",
|
||||
);
|
||||
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus | "">(
|
||||
editOrder?.paymentStatus ?? "",
|
||||
);
|
||||
|
||||
// ── Address state ─────────────────────────────────────────────────────────
|
||||
// Delivery address (عنوان التسليم) — always creates new MongoDB doc
|
||||
const [delCity, setDelCity] = useState(editOrder?.deliveryAddress?.details?.city ?? "");
|
||||
const [delDistrict, setDelDistrict] = useState(editOrder?.deliveryAddress?.details?.district ?? "");
|
||||
const [delStreet, setDelStreet] = useState(editOrder?.deliveryAddress?.details?.street ?? "");
|
||||
const [delBuildingNo, setDelBuildingNo] = useState(editOrder?.deliveryAddress?.details?.buildingNo ?? "");
|
||||
const [delUnitNo, setDelUnitNo] = useState(editOrder?.deliveryAddress?.details?.unitNo ?? "");
|
||||
const [delZipCode, setDelZipCode] = useState(editOrder?.deliveryAddress?.details?.zipCode ?? "");
|
||||
// GeoJSON coordinates [longitude, latitude] — required by backend
|
||||
const [delLng, setDelLng] = useState(
|
||||
editOrder?.deliveryAddress?.location?.coordinates?.[0] != null
|
||||
? String(editOrder.deliveryAddress!.location!.coordinates![0])
|
||||
: "",
|
||||
);
|
||||
const [delLat, setDelLat] = useState(
|
||||
editOrder?.deliveryAddress?.location?.coordinates?.[1] != null
|
||||
? String(editOrder.deliveryAddress!.location!.coordinates![1])
|
||||
: "",
|
||||
);
|
||||
|
||||
// Pickup address (عنوان الاستلام) — إما ID موجود أو object جديد
|
||||
const [pickupMode, setPickupMode] = useState<"id" | "new">(
|
||||
editOrder?.pickupAddressId ? "id" : "new",
|
||||
);
|
||||
const [pickupAddressId, setPickupAddressId] = useState(editOrder?.pickupAddressId ?? "");
|
||||
const [pkpCity, setPkpCity] = useState(editOrder?.pickupAddress?.details?.city ?? "");
|
||||
const [pkpDistrict, setPkpDistrict] = useState(editOrder?.pickupAddress?.details?.district ?? "");
|
||||
const [pkpStreet, setPkpStreet] = useState(editOrder?.pickupAddress?.details?.street ?? "");
|
||||
const [pkpBuildingNo, setPkpBuildingNo] = useState(editOrder?.pickupAddress?.details?.buildingNo ?? "");
|
||||
const [pkpUnitNo, setPkpUnitNo] = useState(editOrder?.pickupAddress?.details?.unitNo ?? "");
|
||||
const [pkpZipCode, setPkpZipCode] = useState(editOrder?.pickupAddress?.details?.zipCode ?? "");
|
||||
// GeoJSON coordinates [longitude, latitude] — optional for pickup
|
||||
const [pkpLng, setPkpLng] = useState(
|
||||
editOrder?.pickupAddress?.location?.coordinates?.[0] != null
|
||||
? String(editOrder.pickupAddress!.location!.coordinates![0])
|
||||
: "",
|
||||
);
|
||||
const [pkpLat, setPkpLat] = useState(
|
||||
editOrder?.pickupAddress?.location?.coordinates?.[1] != null
|
||||
? String(editOrder.pickupAddress!.location!.coordinates![1])
|
||||
: "",
|
||||
);
|
||||
|
||||
|
||||
const [errors, setErrors] = useState<OrderSchemaErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Load clients + trips in parallel on mount ─────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
|
||||
// Fetch first 100 clients (enough for a practical dropdown)
|
||||
const clientRes = await clientService.getAll(1, "", token);
|
||||
const clientPayload = (clientRes as any).data ?? clientRes;
|
||||
const clientList: Client[] = clientPayload.data ?? [];
|
||||
|
||||
// Fetch first 100 trips (active ones the order can be assigned to)
|
||||
const tripRes = await tripService.getAll({ page: 1, limit: 100 }, token);
|
||||
const tripPayload = (tripRes as any).data ?? tripRes;
|
||||
const tripList: Trip[] = tripPayload.data ?? [];
|
||||
|
||||
if (!cancelled) {
|
||||
setClients(clientList);
|
||||
setTrips(tripList);
|
||||
}
|
||||
} catch (err) {
|
||||
// Logged so failures are visible during development instead of
|
||||
// failing completely silently when clientService/tripService error out
|
||||
// (e.g. expired token, CORS, 500). Dropdowns stay empty either way,
|
||||
// and the user can still navigate to the create pages via the helper
|
||||
// links — but now they also see a message explaining why.
|
||||
console.error("OrderFormModal: failed to load clients/trips", err);
|
||||
if (!cancelled) {
|
||||
setApiError("تعذّر تحميل قوائم العملاء والرحلات. تحقق من اتصالك وحاول إعادة فتح النافذة.");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setRelLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// ── Keyboard / focus setup ────────────────────────────────────────────────
|
||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
// ── Dynamic input error style ─────────────────────────────────────────────
|
||||
const inputStyle = (field: keyof OrderSchemaErrors): React.CSSProperties => ({
|
||||
...inputBase,
|
||||
border: errors[field]
|
||||
? "1px solid var(--color-danger)"
|
||||
: inputBase.border,
|
||||
...(errors[field] ? { background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
const clearFieldError = useCallback(
|
||||
(field: keyof OrderSchemaErrors) => setErrors((p) => ({ ...p, [field]: undefined })),
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Yup validation ────────────────────────────────────────────────────────
|
||||
const runValidation = useCallback(async (): Promise<boolean> => {
|
||||
const schema = isNew ? createOrderSchema : updateOrderSchema;
|
||||
try {
|
||||
await schema.validate(
|
||||
{
|
||||
tripId,
|
||||
clientId,
|
||||
shipmentNumber,
|
||||
recipientName,
|
||||
recipientPhone,
|
||||
quantity: quantity ? Number(quantity) : undefined,
|
||||
weight: weight ? Number(weight) : undefined,
|
||||
subTotal: subTotal ? Number(subTotal) : undefined,
|
||||
vatRate: vatRate ? Number(vatRate) : undefined,
|
||||
paymentMethod: paymentMethod || undefined,
|
||||
paymentStatus: paymentStatus || undefined,
|
||||
type: type || undefined,
|
||||
},
|
||||
{ abortEarly: false },
|
||||
);
|
||||
setErrors({});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const bag: OrderSchemaErrors = {};
|
||||
(err as ValidationError).inner.forEach((e) => {
|
||||
if (e.path) (bag as Record<string, string>)[e.path] = e.message;
|
||||
});
|
||||
setErrors(bag);
|
||||
return false;
|
||||
}
|
||||
}, [isNew, tripId, clientId, shipmentNumber, recipientName, recipientPhone,
|
||||
quantity, weight, subTotal, vatRate, paymentMethod, paymentStatus, type]);
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const valid = await runValidation();
|
||||
if (!valid) return;
|
||||
|
||||
// Build clean payload.
|
||||
// NOTE: deliveryAddressId / pickupAddressId are intentionally omitted
|
||||
// from create payloads — the backend creates address records from
|
||||
// deliveryAddressData / pickupAddressData instead.
|
||||
const payload: Record<string, unknown> = {
|
||||
shipmentNumber,
|
||||
recipientName,
|
||||
recipientPhone,
|
||||
clientId,
|
||||
quantity: Number(quantity) || 1,
|
||||
};
|
||||
|
||||
// tripId: only include when a value is selected
|
||||
if (tripId) payload.tripId = tripId;
|
||||
|
||||
// Optional fields — include only when filled
|
||||
if (type) payload.type = type;
|
||||
if (weight) payload.weight = Number(weight);
|
||||
if (subTotal) payload.subTotal = Number(subTotal);
|
||||
if (vatRate) payload.vatRate = Number(vatRate);
|
||||
if (paymentMethod) payload.paymentMethod = paymentMethod;
|
||||
if (paymentStatus) payload.paymentStatus = paymentStatus;
|
||||
|
||||
// ── Build delivery address object (only if at least one field filled) ──
|
||||
const delDetails = {
|
||||
...(delCity && { city: delCity }),
|
||||
...(delDistrict && { district: delDistrict }),
|
||||
...(delStreet && { street: delStreet }),
|
||||
...(delBuildingNo && { buildingNo: delBuildingNo }),
|
||||
...(delUnitNo && { unitNo: delUnitNo }),
|
||||
...(delZipCode && { zipCode: delZipCode }),
|
||||
};
|
||||
// coordinates are always included when provided (required by backend GeoJSON schema)
|
||||
const delCoordinates =
|
||||
delLng.trim() && delLat.trim()
|
||||
? [Number(delLng), Number(delLat)]
|
||||
: undefined;
|
||||
|
||||
if (Object.keys(delDetails).length > 0 || delCoordinates) {
|
||||
payload.deliveryAddress = {
|
||||
...(Object.keys(delDetails).length > 0 && { details: delDetails }),
|
||||
...(delCoordinates && { location: { coordinates: delCoordinates } }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Build pickup address: ID or new object ──
|
||||
if (pickupMode === "id" && pickupAddressId.trim()) {
|
||||
payload.pickupAddressId = pickupAddressId.trim();
|
||||
} else {
|
||||
const pkpDetails = {
|
||||
...(pkpCity && { city: pkpCity }),
|
||||
...(pkpDistrict && { district: pkpDistrict }),
|
||||
...(pkpStreet && { street: pkpStreet }),
|
||||
...(pkpBuildingNo && { buildingNo: pkpBuildingNo }),
|
||||
...(pkpUnitNo && { unitNo: pkpUnitNo }),
|
||||
...(pkpZipCode && { zipCode: pkpZipCode }),
|
||||
};
|
||||
const pkpCoordinates =
|
||||
pkpLng.trim() && pkpLat.trim()
|
||||
? [Number(pkpLng), Number(pkpLat)]
|
||||
: undefined;
|
||||
|
||||
if (Object.keys(pkpDetails).length > 0 || pkpCoordinates) {
|
||||
payload.pickupAddress = {
|
||||
...(Object.keys(pkpDetails).length > 0 && { details: pkpDetails }),
|
||||
...(pkpCoordinates && { location: { coordinates: pkpCoordinates } }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
|
||||
try {
|
||||
const ok = await onSubmit(payload as unknown as CreateOrderPayload, isNew);
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-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: 640,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,.18)",
|
||||
overflow: "hidden", 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="order-modal-title"
|
||||
style={{
|
||||
fontSize: 17, fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0", fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{isNew ? "طلب جديد" : editOrder?.shipmentNumber}
|
||||
</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("")} />
|
||||
)}
|
||||
|
||||
{/* ── Section: Shipment & Recipient ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
|
||||
{/* Shipment number — required */}
|
||||
<label style={labelStyle}>
|
||||
رقم الشحنة *
|
||||
<input
|
||||
ref={firstRef}
|
||||
style={inputStyle("shipmentNumber")}
|
||||
value={shipmentNumber}
|
||||
onChange={(e) => {
|
||||
setShipmentNumber(e.target.value);
|
||||
clearFieldError("shipmentNumber");
|
||||
}}
|
||||
placeholder="SHP-0001"
|
||||
dir="ltr"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.shipmentNumber && (
|
||||
<span style={errorTextStyle}>{errors.shipmentNumber}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* ── Client dropdown — required ── */}
|
||||
<label style={labelStyle}>
|
||||
العميل *
|
||||
<select
|
||||
style={{
|
||||
...inputStyle("clientId"),
|
||||
cursor: relLoading ? "wait" : "pointer",
|
||||
// Shift placeholder text right for RTL
|
||||
paddingRight: "0.75rem",
|
||||
}}
|
||||
value={clientId}
|
||||
disabled={relLoading}
|
||||
onChange={(e) => {
|
||||
setClientId(e.target.value);
|
||||
clearFieldError("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}</span>
|
||||
)}
|
||||
{/* Helper link */}
|
||||
<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>
|
||||
|
||||
{/* Recipient name — required */}
|
||||
<label style={labelStyle}>
|
||||
اسم المستلم *
|
||||
<input
|
||||
style={inputStyle("recipientName")}
|
||||
value={recipientName}
|
||||
onChange={(e) => {
|
||||
setRecipientName(e.target.value);
|
||||
clearFieldError("recipientName");
|
||||
}}
|
||||
placeholder="أحمد محمد"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.recipientName && (
|
||||
<span style={errorTextStyle}>{errors.recipientName}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Recipient phone — required */}
|
||||
<label style={labelStyle}>
|
||||
رقم جوال المستلم *
|
||||
<input
|
||||
style={inputStyle("recipientPhone")}
|
||||
value={recipientPhone}
|
||||
onChange={(e) => {
|
||||
setRecipientPhone(e.target.value);
|
||||
clearFieldError("recipientPhone");
|
||||
}}
|
||||
placeholder="05XXXXXXXX"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.recipientPhone && (
|
||||
<span style={errorTextStyle}>{errors.recipientPhone}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* ── Trip dropdown — required on create, optional on edit ── */}
|
||||
<label style={{ ...labelStyle, gridColumn: "1 / -1" }}>
|
||||
{isNew ? "الرحلة *" : "الرحلة"}
|
||||
{!isNew && <span style={optionalLabelStyle}>(اختياري)</span>}
|
||||
<select
|
||||
style={{
|
||||
...inputStyle("tripId"),
|
||||
cursor: relLoading ? "wait" : "pointer",
|
||||
}}
|
||||
value={tripId ?? ""}
|
||||
disabled={relLoading}
|
||||
onChange={(e) => {
|
||||
setTripId(e.target.value);
|
||||
clearFieldError("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}</span>
|
||||
)}
|
||||
{/* Helper link */}
|
||||
<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>
|
||||
|
||||
{/* ── Section: Shipment details ── */}
|
||||
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
{/* Type — optional */}
|
||||
<label style={labelStyle}>
|
||||
نوع الشحنة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
style={inputBase}
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="عادي / مبرد"
|
||||
dir="rtl"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Quantity — required, defaults to 1 */}
|
||||
<label style={labelStyle}>
|
||||
الكمية *
|
||||
<input
|
||||
style={inputStyle("quantity")}
|
||||
type="number"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
setQuantity(e.target.value);
|
||||
clearFieldError("quantity");
|
||||
}}
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.quantity && (
|
||||
<span style={errorTextStyle}>{errors.quantity}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Weight — optional */}
|
||||
<label style={labelStyle}>
|
||||
الوزن (كجم)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
style={inputBase}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ── Section: Payment ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات الدفع</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
{/* Subtotal — optional */}
|
||||
<label style={labelStyle}>
|
||||
الإجمالي الفرعي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
style={inputStyle("subTotal")}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={subTotal}
|
||||
onChange={(e) => {
|
||||
setSubTotal(e.target.value);
|
||||
clearFieldError("subTotal");
|
||||
}}
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.subTotal && (
|
||||
<span style={errorTextStyle}>{errors.subTotal}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* VAT rate — optional */}
|
||||
<label style={labelStyle}>
|
||||
نسبة الضريبة (%)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
style={inputStyle("vatRate")}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={vatRate}
|
||||
onChange={(e) => {
|
||||
setVatRate(e.target.value);
|
||||
clearFieldError("vatRate");
|
||||
}}
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.vatRate && (
|
||||
<span style={errorTextStyle}>{errors.vatRate}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Payment method — optional */}
|
||||
<label style={labelStyle}>
|
||||
طريقة الدفع
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<select
|
||||
style={{ ...inputBase, cursor: "pointer" }}
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value as PaymentMethod | "")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر الطريقة</option>
|
||||
<option value="Cash">نقداً</option>
|
||||
<option value="Card">بطاقة</option>
|
||||
<option value="Prepaid">مدفوع مسبقاً</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Payment status — edit-only, mirrors Driver's status-on-edit pattern */}
|
||||
{!isNew && (
|
||||
<label style={labelStyle}>
|
||||
حالة الدفع
|
||||
<select
|
||||
style={{ ...inputBase, cursor: "pointer" }}
|
||||
value={paymentStatus}
|
||||
onChange={(e) =>
|
||||
setPaymentStatus(e.target.value as 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>
|
||||
|
||||
{/* ── Section: Delivery Address ── */}
|
||||
<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} value={delCity} onChange={(e) => setDelCity(e.target.value)} placeholder="الرياض" dir="rtl" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الحي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delDistrict} onChange={(e) => setDelDistrict(e.target.value)} placeholder="العليا" dir="rtl" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الشارع
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delStreet} onChange={(e) => setDelStreet(e.target.value)} placeholder="شارع الأمير محمد" dir="rtl" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم المبنى
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delBuildingNo} onChange={(e) => setDelBuildingNo(e.target.value)} placeholder="1234" dir="ltr" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم الوحدة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delUnitNo} onChange={(e) => setDelUnitNo(e.target.value)} placeholder="56" dir="ltr" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الرمز البريدي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={delZipCode} onChange={(e) => setDelZipCode(e.target.value)} placeholder="12345" dir="ltr" />
|
||||
</label>
|
||||
{/* GeoJSON coordinates — required by backend */}
|
||||
<label style={labelStyle}>
|
||||
خط الطول (Longitude) *
|
||||
<input
|
||||
style={inputBase}
|
||||
type="number"
|
||||
step="any"
|
||||
value={delLng}
|
||||
onChange={(e) => setDelLng(e.target.value)}
|
||||
placeholder="46.6753"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
خط العرض (Latitude) *
|
||||
<input
|
||||
style={inputBase}
|
||||
type="number"
|
||||
step="any"
|
||||
value={delLat}
|
||||
onChange={(e) => setDelLat(e.target.value)}
|
||||
placeholder="24.7136"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ── Section: Pickup Address ── */}
|
||||
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
|
||||
|
||||
{/* Toggle: existing ID or new address */}
|
||||
<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}
|
||||
value={pickupAddressId}
|
||||
onChange={(e) => setPickupAddressId(e.target.value)}
|
||||
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} value={pkpCity} onChange={(e) => setPkpCity(e.target.value)} placeholder="جدة" dir="rtl" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الحي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpDistrict} onChange={(e) => setPkpDistrict(e.target.value)} placeholder="الروضة" dir="rtl" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الشارع
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpStreet} onChange={(e) => setPkpStreet(e.target.value)} placeholder="شارع التحلية" dir="rtl" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم المبنى
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpBuildingNo} onChange={(e) => setPkpBuildingNo(e.target.value)} placeholder="4321" dir="ltr" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
رقم الوحدة
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpUnitNo} onChange={(e) => setPkpUnitNo(e.target.value)} placeholder="12" dir="ltr" />
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
الرمز البريدي
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input style={inputBase} value={pkpZipCode} onChange={(e) => setPkpZipCode(e.target.value)} placeholder="23456" dir="ltr" />
|
||||
</label>
|
||||
{/* GeoJSON coordinates — optional for pickup */}
|
||||
<label style={labelStyle}>
|
||||
خط الطول (Longitude)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
style={inputBase}
|
||||
type="number"
|
||||
step="any"
|
||||
value={pkpLng}
|
||||
onChange={(e) => setPkpLng(e.target.value)}
|
||||
placeholder="46.6753"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
خط العرض (Latitude)
|
||||
<span style={optionalLabelStyle}>(اختياري)</span>
|
||||
<input
|
||||
style={inputBase}
|
||||
type="number"
|
||||
step="any"
|
||||
value={pkpLat}
|
||||
onChange={(e) => setPkpLat(e.target.value)}
|
||||
placeholder="24.7136"
|
||||
dir="ltr"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
"use client";
|
||||
import React, { type FC } from "react";
|
||||
import { useClientOrders } from "@/src/hooks/useClientOrders";
|
||||
import { fmtDate, fmtAmount } from "@/src/lib/formatters";
|
||||
import { statusColor, statusLabel } from "@/src/lib/order-status";
|
||||
import { clearSession, type ClientSession } from "@/src/lib/session";
|
||||
import type { OrderStatus } from "@/src/types/order";
|
||||
import { Spinner } from "../UI";
|
||||
|
||||
// ─── Order status tracker ─────────────────────
|
||||
const STATUS_STEPS: OrderStatus[] = ["CREATED", "IN_TRANSIT", "DELIVERED"];
|
||||
const STEP_LABELS = ["تم الإنشاء", "قيد التوصيل", "تم التسليم"];
|
||||
|
||||
const StepIcon: FC<{ step: number }> = ({ step }) => {
|
||||
if (step === 0) return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||
<path d="M4 7h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z"/>
|
||||
</svg>
|
||||
);
|
||||
if (step === 1) return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||
<line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const OrderTracker: FC<{ status: OrderStatus }> = ({ status }) => {
|
||||
const idx = STATUS_STEPS.indexOf(status === "CANCELLED" ? "CREATED" : status);
|
||||
return (
|
||||
<div className="flex items-center gap-0 py-2" dir="rtl" role="list" aria-label="تقدم الطلب">
|
||||
{STATUS_STEPS.map((s, i) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex flex-col items-center gap-1" role="listitem">
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
|
||||
status === "CANCELLED"
|
||||
? "bg-[#FEE2E2] text-red-500 border-2 border-red-200"
|
||||
: i <= idx ? "bg-[#1A73E8] text-white" : "bg-white border-2 border-[#E5E7EB] text-[#D1D5DB]"
|
||||
}`} aria-current={i === idx ? "step" : undefined}>
|
||||
<StepIcon step={i} />
|
||||
</div>
|
||||
<span className={`text-[10px] font-semibold whitespace-nowrap ${
|
||||
status === "CANCELLED" ? "text-red-400" : i <= idx ? "text-[#1A73E8]" : "text-[#9CA3AF]"
|
||||
}`}>
|
||||
{STEP_LABELS[i]}
|
||||
</span>
|
||||
</div>
|
||||
{i < STATUS_STEPS.length - 1 && (
|
||||
<div aria-hidden="true" className={`flex-1 h-0.5 mb-4 mx-1 ${
|
||||
status === "CANCELLED" ? "bg-red-200" : i < idx ? "bg-[#1A73E8]" : "bg-[#E5E7EB]"
|
||||
}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── DashboardStep ───────────────────────────
|
||||
interface DashboardStepProps {
|
||||
session: ClientSession;
|
||||
}
|
||||
|
||||
const DashboardStep: FC<DashboardStepProps> = ({ session }) => {
|
||||
const { orders, loading, error } = useClientOrders(session.id);
|
||||
|
||||
const activeOrder = orders.find(o => o.status === "CREATED" || o.status === "IN_TRANSIT");
|
||||
const pastOrders = orders.filter(o => o !== activeOrder);
|
||||
|
||||
const summary = {
|
||||
total: orders.length,
|
||||
delivered: orders.filter(o => o.status === "DELIVERED").length,
|
||||
pending: orders.filter(o => o.status === "CREATED" || o.status === "IN_TRANSIT").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div dir="rtl">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[22px] font-bold text-[#111827] tracking-tight">لوحة الطلبات</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5">
|
||||
مرحبًا بعودتك، <strong className="text-[#111827]">{session.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-[#D1FAE5] border border-[#A7F3D0] rounded-full px-3 py-1.5" role="status" aria-label="الحساب نشط">
|
||||
<span aria-hidden="true" className="w-2 h-2 rounded-full bg-[#10B981] motion-safe:animate-pulse" />
|
||||
<span className="text-[11px] font-bold text-[#065F46]">نشط</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-14 gap-3" role="status">
|
||||
<Spinner />
|
||||
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل الطلبات…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex flex-col items-center gap-3 py-10" role="alert">
|
||||
<p className="text-[13px] text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
{[
|
||||
{ label: "إجمالي الطلبات", value: summary.total, color: "text-[#111827]" },
|
||||
{ label: "مُسلَّمة", value: summary.delivered, color: "text-[#34A853]" },
|
||||
{ label: "قيد التنفيذ", value: summary.pending, color: "text-[#1A73E8]" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white border border-[#E5E7EB] rounded-xl p-3.5">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] mb-1">{s.label}</p>
|
||||
<p className={`text-[26px] font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Active order tracker */}
|
||||
{activeOrder ? (
|
||||
<div className="bg-gradient-to-bl from-[#0D47A1] to-[#1A73E8] rounded-xl p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-white/60 tracking-wide">الطلب الحالي</p>
|
||||
<p className="text-[14px] font-bold text-white font-mono">{activeOrder.id}</p>
|
||||
</div>
|
||||
<span className={`text-[11px] font-bold border px-2.5 py-1 rounded-full ${
|
||||
activeOrder.status === "CREATED"
|
||||
? "bg-white/15 border-white/30 text-white"
|
||||
: "bg-[#D1FAE5] border-[#A7F3D0] text-[#065F46]"
|
||||
}`}>
|
||||
{statusLabel(activeOrder.status)}
|
||||
</span>
|
||||
</div>
|
||||
<OrderTracker status={activeOrder.status} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed border-[#E5E7EB] rounded-xl p-5 mb-6 text-center">
|
||||
<p className="text-[13px] text-[#9CA3AF]">لا يوجد طلب نشط حاليًا.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past orders table */}
|
||||
{pastOrders.length > 0 && (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#E5E7EB] flex items-center justify-between">
|
||||
<p className="text-[13px] font-bold text-[#111827]">سجل الطلبات</p>
|
||||
<span className="text-[11px] text-[#9CA3AF] font-medium">{pastOrders.length} طلبات</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" dir="rtl">
|
||||
<thead>
|
||||
<tr className="bg-[#F9FAFB]">
|
||||
{["رقم الطلب", "التاريخ", "الوصف", "الحالة", "المبلغ"].map(h => (
|
||||
<th key={h} scope="col" className="px-4 py-2.5 text-right text-[10px] font-bold text-[#9CA3AF] uppercase tracking-wide">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pastOrders.map((order, i) => (
|
||||
<tr key={order.id} className={`border-t border-[#F3F4F6] hover:bg-[#FAFAFA] transition-colors ${i % 2 === 0 ? "" : "bg-[#FAFAFA]/30"}`}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[12px] font-semibold text-[#1A73E8]">{order.id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-[#6B7280] whitespace-nowrap">{fmtDate(order.createdAt)}</td>
|
||||
<td className="px-4 py-3 text-[12px] text-[#374151]">{order.description}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 text-[11px] font-bold border px-2.5 py-0.5 rounded-full whitespace-nowrap ${statusColor(order.status)}`}>
|
||||
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
{statusLabel(order.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] font-semibold text-[#111827] font-mono whitespace-nowrap">
|
||||
{fmtAmount(order.totalAmount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { clearSession(); window.location.reload(); }}
|
||||
className="mt-4 text-[11px] text-[#9CA3AF] hover:text-red-400 underline transition-colors"
|
||||
>
|
||||
مسح الجلسة وإعادة البدء
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardStep;
|
||||
Reference in New Issue
Block a user