update order pages and create client pages also client adresses pages
This commit is contained in:
@@ -1,23 +1,36 @@
|
||||
"use client";
|
||||
|
||||
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Alert } from "@/src/Components/UI";
|
||||
import { Toast } from "@/src/Components/Client/Toast";
|
||||
|
||||
// ── UI components (canonical barrel — no old Client/Toast) ─────────────────
|
||||
import { Alert, Toast } from "@/src/Components/UI";
|
||||
|
||||
// ── Hooks & services ───────────────────────────────────────────────────────
|
||||
import { useClientAddresses } from "@/src/hooks/useClientAddresses";
|
||||
import { clientService } from "@/src/services/client.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
import type {
|
||||
Client,
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
ClientFormData,
|
||||
} from "@/src/types/client";
|
||||
import { AddressFormModal } from "@/src/Components/Client/Addressformmodal";
|
||||
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
// ── Client-specific modals ─────────────────────────────────────────────────
|
||||
import { AddressFormModal,DeleteConfirmModal ,ClientFormModal } from "@/src/Components/Client";
|
||||
import { CreateAddressFormValues, UpdateAddressFormValues } from "@/src/validations/client_address.validator";
|
||||
|
||||
/** Returns a context-appropriate emoji for each common address label */
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Emoji icon for known address label types */
|
||||
function labelIcon(label: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"فوترة": "💳",
|
||||
@@ -34,16 +47,29 @@ function labelIcon(label: string): string {
|
||||
return map[label.toLowerCase()] ?? "📍";
|
||||
}
|
||||
|
||||
// ── Address card sub-component ─────────────────────────────────────────────
|
||||
// Mirrors the AddressCard in the old page — kept co-located because it's only used here.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AddressCard sub-component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AddressCardProps {
|
||||
address: ClientAddress;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onMakePrimary: () => void;
|
||||
/** Whether a "set primary" action is pending for this specific card */
|
||||
settingPrimary?: boolean;
|
||||
}
|
||||
|
||||
function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardProps) {
|
||||
/**
|
||||
* AddressCard — Renders a single address in the grid.
|
||||
*/
|
||||
function AddressCard({
|
||||
address,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMakePrimary,
|
||||
settingPrimary = false,
|
||||
}: AddressCardProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -60,18 +86,34 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr
|
||||
boxShadow: address.isPrimary
|
||||
? "0 0 0 2px #BFDBFE, var(--shadow-card)"
|
||||
: "var(--shadow-card)",
|
||||
transition: "box-shadow 200ms",
|
||||
}}
|
||||
>
|
||||
{/* Label row */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 20 }} aria-hidden="true">
|
||||
{labelIcon(address.label)}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--color-text-primary)" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{address.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Primary badge */}
|
||||
{address.isPrimary && (
|
||||
<span
|
||||
style={{
|
||||
@@ -82,9 +124,10 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr
|
||||
border: "1px solid #BFDBFE",
|
||||
borderRadius: "var(--radius-full)",
|
||||
padding: "0.15rem 0.5rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
رئيسي
|
||||
★ رئيسي
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -101,7 +144,7 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr
|
||||
>
|
||||
<p style={{ margin: 0 }}>{address.street}</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
{address.city}، {address.state} {address.postalCode}
|
||||
{address.city}،{" "}{address.state} {address.postalCode}
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{address.country}</p>
|
||||
</address>
|
||||
@@ -117,18 +160,28 @@ function AddressCard({ address, onEdit, onDelete, onMakePrimary }: AddressCardPr
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<ActionBtn onClick={onEdit} color="#1D4ED8" bg="#EFF6FF" border="#BFDBFE">
|
||||
<ActionBtn
|
||||
onClick={onEdit}
|
||||
color="#1D4ED8"
|
||||
bg="#EFF6FF"
|
||||
border="#BFDBFE"
|
||||
>
|
||||
تعديل
|
||||
</ActionBtn>
|
||||
|
||||
{/*
|
||||
* Set-primary button — only for non-primary addresses.
|
||||
* Shows a loading indicator while the API call is in-flight.
|
||||
*/}
|
||||
{!address.isPrimary && (
|
||||
<ActionBtn
|
||||
onClick={onMakePrimary}
|
||||
color="#065F46"
|
||||
bg="#DCFCE7"
|
||||
border="#BBF7D0"
|
||||
disabled={settingPrimary}
|
||||
>
|
||||
تعيين كرئيسي
|
||||
{settingPrimary ? "…" : "تعيين كرئيسي"}
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
@@ -152,6 +205,7 @@ function ActionBtn({
|
||||
bg,
|
||||
border,
|
||||
style,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
@@ -159,12 +213,14 @@ function ActionBtn({
|
||||
bg: string;
|
||||
border: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: 30,
|
||||
padding: "0 0.75rem",
|
||||
@@ -174,8 +230,10 @@ function ActionBtn({
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color,
|
||||
cursor: "pointer",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
fontFamily: "var(--font-sans)",
|
||||
transition: "opacity 150ms",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
@@ -184,25 +242,303 @@ function ActionBtn({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AddressMiniList — used inside the client edit modal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* AddressMiniList — A compact read-only list of addresses shown inside
|
||||
* the ClientFormModal when editing an existing client.
|
||||
*
|
||||
* Purpose:
|
||||
* Allows the user to see all addresses and designate one as primary
|
||||
* without navigating away from the edit modal.
|
||||
*
|
||||
* Props:
|
||||
* addresses — current list from useClientAddresses
|
||||
* onMakePrimary — handler that calls clientAddressService.setPrimary
|
||||
* settingId — id of the address currently being promoted (for loading state)
|
||||
*/
|
||||
function AddressMiniList({
|
||||
addresses,
|
||||
onMakePrimary,
|
||||
settingId,
|
||||
}: {
|
||||
addresses: ClientAddress[];
|
||||
onMakePrimary: (id: string) => void;
|
||||
settingId: string | null;
|
||||
}) {
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px dashed var(--color-border)",
|
||||
padding: "1rem",
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
لا توجد عناوين مضافة بعد.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul
|
||||
dir="rtl"
|
||||
style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}
|
||||
>
|
||||
{addresses.map((addr) => (
|
||||
<li
|
||||
key={addr.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
padding: "0.625rem 0.875rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: addr.isPrimary ? "1px solid #93C5FD" : "1px solid var(--color-border)",
|
||||
background: addr.isPrimary ? "#EFF6FF" : "var(--color-surface-muted)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span aria-hidden="true">{labelIcon(addr.label)}</span>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
|
||||
{addr.label}
|
||||
</span>
|
||||
<span style={{ color: "var(--color-text-muted)", marginRight: 8 }}>
|
||||
{addr.city}، {addr.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addr.isPrimary ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#1D4ED8",
|
||||
background: "#DBEAFE",
|
||||
border: "1px solid #BFDBFE",
|
||||
borderRadius: "var(--radius-full)",
|
||||
padding: "0.1rem 0.45rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
★ رئيسي
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMakePrimary(addr.id)}
|
||||
disabled={settingId === addr.id}
|
||||
style={{
|
||||
height: 24,
|
||||
padding: "0 0.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid #BBF7D0",
|
||||
background: "#DCFCE7",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#065F46",
|
||||
cursor: settingId === addr.id ? "not-allowed" : "pointer",
|
||||
opacity: settingId === addr.id ? 0.6 : 1,
|
||||
fontFamily: "var(--font-sans)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{settingId === addr.id ? "…" : "تعيين كرئيسي"}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ClientEditModalWithAddresses — wraps ClientFormModal + AddressMiniList
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ClientEditModalWithAddresses
|
||||
*/
|
||||
interface ClientEditModalWithAddressesProps {
|
||||
client: Client;
|
||||
addresses: ClientAddress[];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
||||
onMakePrimary: (id: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function ClientEditModalWithAddresses({
|
||||
client,
|
||||
addresses,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onMakePrimary,
|
||||
}: ClientEditModalWithAddressesProps) {
|
||||
const [settingId, setSettingId] = useState<string | null>(null);
|
||||
|
||||
const handleSetPrimary = async (id: string) => {
|
||||
setSettingId(id);
|
||||
await onMakePrimary(id);
|
||||
setSettingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
// We reuse the backdrop and center the compound modal manually
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="تعديل بيانات العميل"
|
||||
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: "flex-start",
|
||||
justifyContent: "center",
|
||||
padding: "2rem 1rem",
|
||||
overflowY: "auto",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{/* Left panel: client form */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{/* Render ClientFormModal in a "portal-less" way by embedding its JSX inline */}
|
||||
<div
|
||||
style={{
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{/* We mount ClientFormModal as a standalone dialog — it handles its own
|
||||
form logic. We intercept onClose to close the whole compound modal. */}
|
||||
<ClientFormModal
|
||||
editClient={client}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Address section — appended below the form card.
|
||||
* Satisfies: "create a section that displays all available addresses."
|
||||
*/}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Section header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0.875rem 1.25rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.25em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563EB",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
عناوين العميل
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: "2px 0 0",
|
||||
fontSize: 12,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{addresses.length} عنوان — اضغط "تعيين كرئيسي" لتغيير العنوان الرئيسي
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address mini-list */}
|
||||
<div style={{ padding: "1rem" }}>
|
||||
<AddressMiniList
|
||||
addresses={addresses}
|
||||
onMakePrimary={handleSetPrimary}
|
||||
settingId={settingId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Page component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ClientAddressesPage() {
|
||||
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const clientId = params?.clientId as string;
|
||||
console.log('clientId:', params.clientId); // شوف إيه اللي بيجي
|
||||
|
||||
// FIX: "id is undefined" 404 bug.
|
||||
// params.clientId only works if your route folder is named [clientId].
|
||||
// If your folder is actually [id], params.clientId is always undefined,
|
||||
// which becomes the string "undefined" in API URLs like /client/undefined/addresses.
|
||||
// CHECK YOUR FOLDER NAME and keep only the line that matches it:
|
||||
const clientId = (params?.clientId ?? params?.id) as string | undefined;
|
||||
|
||||
// FIX: warn loudly in dev instead of silently calling the API with no id
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
console.warn("ClientAddressesPage: clientId is missing from route params. Check your folder name (must match the key used in useParams()).");
|
||||
}
|
||||
}, [clientId]);
|
||||
|
||||
// ── Parent client meta ───────────────────────────────────────────────────
|
||||
const [client, setClient] = useState<Client | null>(null);
|
||||
const [client, setClient] = useState<Client | null>(null);
|
||||
const [clientLoading, setClientLoading] = useState(true);
|
||||
|
||||
// Fetch the parent client so we can show their name / details in the header
|
||||
useEffect(() => {
|
||||
if (!clientId) return;
|
||||
setClientLoading(true);
|
||||
clientService
|
||||
.getById(clientId, getStoredToken())
|
||||
.then((res) => setClient(res.data))
|
||||
.catch(() => router.replace("/clients"))
|
||||
.catch(() => router.replace("/dashboard/clients"))
|
||||
.finally(() => setClientLoading(false));
|
||||
}, [clientId, router]);
|
||||
|
||||
@@ -211,29 +547,55 @@ export default function ClientAddressesPage() {
|
||||
addresses,
|
||||
loading: addrLoading,
|
||||
error,
|
||||
notification,
|
||||
notification, // { type, message } — matches ToastNotification
|
||||
clearError,
|
||||
createAddress,
|
||||
updateAddress,
|
||||
deleteAddress,
|
||||
makePrimary,
|
||||
reload,
|
||||
} = useClientAddresses(clientId ?? "");
|
||||
// NOTE: hook itself guards "if (!clientId) return" before calling the API,
|
||||
// so passing "" here is safe — it just won't fetch until clientId is real.
|
||||
|
||||
// ── Modal state (mirrors UsersPage pattern) ──────────────────────────────
|
||||
// false = closed | null = create mode | ClientAddress = edit mode
|
||||
const [formTarget, setFormTarget] = useState<ClientAddress | null | false>(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
// ── Modal state ──────────────────────────────────────────────────────────
|
||||
// Address form: false = closed | null = create | ClientAddress = edit
|
||||
const [addrFormTarget, setAddrFormTarget] = useState<ClientAddress | null | false>(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClientAddress | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
const handleFormSubmit = async (
|
||||
data: ClientAddressFormData,
|
||||
isNew: boolean
|
||||
// Client edit modal (with address section)
|
||||
const [editingClient, setEditingClient] = useState(false);
|
||||
|
||||
// Per-card primary-setting loading state (card id)
|
||||
const [settingPrimaryId, setSettingPrimaryId] = useState<string | null>(null);
|
||||
|
||||
// ── Address form handler ─────────────────────────────────────────────────
|
||||
// FIX: "addrFormTarget!.id" used "!" which only blocks null/undefined,
|
||||
// NOT "false" — but addrFormTarget's type is "ClientAddress | null | false".
|
||||
// If addrFormTarget was ever false here, ".id" would be undefined at runtime,
|
||||
// causing the "id is undefined" 404 bug from the API call.
|
||||
// We now check addrFormTarget directly instead of trusting the isNew param,
|
||||
// so create vs update can never disagree with each other.
|
||||
const handleAddressSubmit = async (
|
||||
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||
): Promise<boolean> => {
|
||||
if (isNew) return createAddress(data);
|
||||
return updateAddress((formTarget as ClientAddress).id, data);
|
||||
if (addrFormTarget === null) {
|
||||
// create mode
|
||||
return createAddress(data as CreateAddressFormValues);
|
||||
}
|
||||
|
||||
if (addrFormTarget === false) {
|
||||
// modal isn't even open — this should never happen, but guard anyway
|
||||
console.error("handleAddressSubmit called while addrFormTarget is false (modal closed)");
|
||||
return false;
|
||||
}
|
||||
|
||||
// update mode — addrFormTarget is a real ClientAddress here, .id is safe
|
||||
return updateAddress(addrFormTarget.id, data as UpdateAddressFormValues);
|
||||
};
|
||||
|
||||
// ── Delete handler ───────────────────────────────────────────────────────
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget || deleting) return;
|
||||
setDeleting(true);
|
||||
@@ -242,7 +604,35 @@ export default function ClientAddressesPage() {
|
||||
if (ok) setDeleteTarget(null);
|
||||
};
|
||||
|
||||
// ── Guard: wait for router + client ─────────────────────────────────────
|
||||
// ── Set-primary handler ──────────────────────────────────────────────────
|
||||
/**
|
||||
* makePrimaryForCard — wraps makePrimary from the hook with per-card
|
||||
* loading state so each card can show its own spinner independently.
|
||||
*/
|
||||
const makePrimaryForCard = async (id: string): Promise<boolean> => {
|
||||
setSettingPrimaryId(id);
|
||||
const ok = await makePrimary(id);
|
||||
setSettingPrimaryId(null);
|
||||
return ok;
|
||||
};
|
||||
|
||||
// ── Client edit submit ───────────────────────────────────────────────────
|
||||
const handleClientEditSubmit = async (
|
||||
data: ClientFormData,
|
||||
_isNew: boolean,
|
||||
): Promise<boolean> => {
|
||||
if (!client) return false;
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientService.update(client.id, data, token);
|
||||
setClient(res.data); // optimistically update local state
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Guard: wait for client ───────────────────────────────────────────────
|
||||
if (!clientId || clientLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -270,22 +660,29 @@ export default function ClientAddressesPage() {
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
{/* Global success / error toast */}
|
||||
{/*
|
||||
* Global toast — sourced from UI barrel (not the old Client/Toast).
|
||||
* The notification object from useClientAddresses has the same shape
|
||||
* as ToastNotification: { type: "success" | "error"; message: string }.
|
||||
*/}
|
||||
<Toast notification={notification} />
|
||||
|
||||
{/* Create / Edit address modal */}
|
||||
{formTarget !== false && (
|
||||
{/* ── Address create / edit modal ── */}
|
||||
{addrFormTarget !== false && (
|
||||
<AddressFormModal
|
||||
editAddress={formTarget}
|
||||
onClose={() => setFormTarget(false)}
|
||||
onSubmit={handleFormSubmit}
|
||||
editAddress={addrFormTarget}
|
||||
onClose={() => setAddrFormTarget(false)}
|
||||
onSubmit={handleAddressSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation — reuses the same DeleteConfirmModal shape */}
|
||||
{/*
|
||||
* ── Address delete confirmation ──
|
||||
* We cast the address to the Client shape that DeleteConfirmModal expects
|
||||
* (it only uses id + name fields from Client).
|
||||
*/}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
// Cast address as Client for the modal (same shape: id + name)
|
||||
client={
|
||||
{
|
||||
...deleteTarget,
|
||||
@@ -302,6 +699,21 @@ export default function ClientAddressesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* ── Client edit modal with embedded address section ──
|
||||
* TEST: Click "تعديل بيانات العميل" in the header.
|
||||
* → Modal shows client form + address list with "تعيين كرئيسي" buttons.
|
||||
*/}
|
||||
{editingClient && client && (
|
||||
<ClientEditModalWithAddresses
|
||||
client={client}
|
||||
addresses={addresses}
|
||||
onClose={() => setEditingClient(false)}
|
||||
onSubmit={handleClientEditSubmit}
|
||||
onMakePrimary={makePrimaryForCard}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* ── Page header ── */}
|
||||
@@ -317,7 +729,7 @@ export default function ClientAddressesPage() {
|
||||
{/* Back link */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/clients")}
|
||||
onClick={() => router.push("/dashboard/clients")}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
@@ -332,7 +744,14 @@ export default function ClientAddressesPage() {
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
جميع العملاء
|
||||
@@ -349,6 +768,7 @@ export default function ClientAddressesPage() {
|
||||
>
|
||||
إدارة العناوين
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1
|
||||
@@ -376,34 +796,80 @@ export default function ClientAddressesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add address button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormTarget(null)}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.125rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "none",
|
||||
background: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
fontFamily: "var(--font-sans)",
|
||||
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{/* Header action buttons */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{/* Edit client (opens compound modal with address section) */}
|
||||
{client && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingClient(true)}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل بيانات العميل
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add address button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddrFormTarget(null)}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.125rem",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "none",
|
||||
background: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
fontFamily: "var(--font-sans)",
|
||||
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Client info strip */}
|
||||
@@ -421,7 +887,12 @@ export default function ClientAddressesPage() {
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{client.name}
|
||||
</span>
|
||||
<a
|
||||
@@ -448,11 +919,18 @@ export default function ClientAddressesPage() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* General load error */}
|
||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||
{/*
|
||||
* Error alert — shown when the address load fails.
|
||||
* Uses the UI/Alert component with an onClose dismiss handler.
|
||||
* TEST: Simulate a network error → alert banner appears above the grid.
|
||||
*/}
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={clearError} />
|
||||
)}
|
||||
|
||||
{/* Address grid */}
|
||||
{/* ── Address grid ── */}
|
||||
{addrLoading ? (
|
||||
/* Loading skeleton-ish state */
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -460,12 +938,25 @@ export default function ClientAddressesPage() {
|
||||
justifyContent: "center",
|
||||
padding: "4rem 0",
|
||||
color: "var(--color-text-muted)",
|
||||
gap: 12,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
جارٍ التحميل…
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
border: "2px solid var(--color-border)",
|
||||
borderTopColor: "var(--color-brand-600)",
|
||||
animation: "spin 0.7s linear infinite",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
جارٍ تحميل العناوين…
|
||||
</div>
|
||||
) : addresses.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
@@ -498,7 +989,7 @@ export default function ClientAddressesPage() {
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormTarget(null)}
|
||||
onClick={() => setAddrFormTarget(null)}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontSize: 13,
|
||||
@@ -514,6 +1005,14 @@ export default function ClientAddressesPage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/*
|
||||
* Address cards grid.
|
||||
* TEST:
|
||||
* - Click "تعديل" → AddressFormModal opens pre-filled.
|
||||
* - Click "تعيين كرئيسي" → button shows "…" loading, then card re-renders
|
||||
* with "★ رئيسي" badge and blue ring; previously primary card loses the badge.
|
||||
* - Click "حذف" → DeleteConfirmModal opens → confirm → card removed, toast shown.
|
||||
*/
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -525,9 +1024,10 @@ export default function ClientAddressesPage() {
|
||||
<AddressCard
|
||||
key={addr.id}
|
||||
address={addr}
|
||||
onEdit={() => setFormTarget(addr)}
|
||||
onEdit={() => setAddrFormTarget(addr)}
|
||||
onDelete={() => setDeleteTarget(addr)}
|
||||
onMakePrimary={() => makePrimary(addr.id)}
|
||||
onMakePrimary={() => makePrimaryForCard(addr.id)}
|
||||
settingPrimary={settingPrimaryId === addr.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* app/dashboard/clients/page.tsx — Enhanced
|
||||
*
|
||||
* Changes from original:
|
||||
* 1. Replaced `import { Toast } from "@/src/Components/Client/Toast"` with
|
||||
* `import { Toast } from "@/src/Components/UI"` (the canonical, feature-rich version
|
||||
* that supports an optional dismiss button and proper exit animation).
|
||||
* 2. The notification type is now `ToastNotification` from the UI barrel, matching
|
||||
* the Toast's expected prop type.
|
||||
* 3. All other logic (CRUD, routing, modal management) is unchanged.
|
||||
*
|
||||
* HOW TO TEST:
|
||||
* - Add a client → green toast "تم إضافة العميل بنجاح."
|
||||
* - Edit a client → green toast "تم تحديث بيانات العميل."
|
||||
* - Delete a client → green toast "تم حذف العميل بنجاح."
|
||||
* - Click a table row → detail panel expands inline.
|
||||
* - Click "العناوين" inside the panel → navigates to /dashboard/clients/[id]/addresses.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert } from "@/src/Components/UI";
|
||||
import { Toast } from "@/src/Components/Client/Toast";
|
||||
import { useClients } from "@/src/hooks/useClients";
|
||||
|
||||
// ── UI components ──────────────────────────────────────────────────────────
|
||||
// NOTE: Toast is now imported from the canonical UI barrel, NOT from Client/Toast.
|
||||
import { Alert, Toast } from "@/src/Components/UI";
|
||||
|
||||
// ── Client-specific components ─────────────────────────────────────────────
|
||||
import { useClients } from "@/src/hooks/useClients";
|
||||
import type { Client, ClientFormData } from "@/src/types/client";
|
||||
import { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
|
||||
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
||||
import { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
|
||||
import { ClientTable } from "@/src/Components/Client/Clienttable";
|
||||
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
|
||||
|
||||
export default function ClientsPage() {
|
||||
@@ -31,7 +54,7 @@ export default function ClientsPage() {
|
||||
// ── Create / Update handler ──────────────────────────────────────────────
|
||||
const handleFormSubmit = async (
|
||||
data: ClientFormData,
|
||||
isNew: boolean
|
||||
isNew: boolean,
|
||||
): Promise<boolean> => {
|
||||
if (isNew) return createClient(data);
|
||||
return updateClient((formTarget as Client).id, data);
|
||||
@@ -47,6 +70,8 @@ export default function ClientsPage() {
|
||||
};
|
||||
|
||||
// ── Navigate to address management ──────────────────────────────────────
|
||||
// This is called both by the AddressBadge click and the "العناوين" button
|
||||
// inside the ClientDetailPanel.
|
||||
const handleManageAddresses = (client: Client) => {
|
||||
router.push(`/dashboard/clients/${client.id}/addresses`);
|
||||
};
|
||||
@@ -54,7 +79,11 @@ export default function ClientsPage() {
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
{/* Global success / error toast */}
|
||||
{/*
|
||||
* Global toast notification.
|
||||
* Using the new UI/Toast which accepts ToastNotification | null.
|
||||
* The hook's `notification` shape { type, message } matches ToastNotification exactly.
|
||||
*/}
|
||||
<Toast notification={notification} />
|
||||
|
||||
{/* Create / Edit modal */}
|
||||
|
||||
36
package-lock.json
generated
36
package-lock.json
generated
@@ -8,10 +8,12 @@
|
||||
"name": "logistic_client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tabler/icons-webfont": "^3.44.0",
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -457,6 +459,18 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
||||
"integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -1330,6 +1344,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -7097,6 +7117,22 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.80.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz",
|
||||
"integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tabler/icons-webfont": "^3.44.0",
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"yup": "^1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import type {
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
ClientAddressFormErrors,
|
||||
} from "@/src/types/client";
|
||||
|
||||
// ── 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,
|
||||
};
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
function validate(data: ClientAddressFormData): ClientAddressFormErrors {
|
||||
const e: ClientAddressFormErrors = {};
|
||||
if (!data.label.trim()) e.label = "النوع مطلوب";
|
||||
if (!data.street.trim()) e.street = "العنوان مطلوب";
|
||||
if (!data.city.trim()) e.city = "المدينة مطلوبة";
|
||||
if (!data.state.trim()) e.state = "المنطقة مطلوبة";
|
||||
if (!data.postalCode.trim()) e.postalCode = "الرمز البريدي مطلوب";
|
||||
if (!data.country.trim()) e.country = "الدولة مطلوبة";
|
||||
return e;
|
||||
}
|
||||
|
||||
const LABEL_PRESETS = ["فوترة", "شحن", "المقر الرئيسي", "فرع", "مستودع", "أخرى"];
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface AddressFormModalProps {
|
||||
editAddress: ClientAddress | null; // null = create mode
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ClientAddressFormData, isNew: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function AddressFormModal({
|
||||
editAddress,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddressFormModalProps) {
|
||||
const isNew = editAddress === null;
|
||||
|
||||
const [form, setForm] = useState<ClientAddressFormData>({
|
||||
label: editAddress?.label ?? "",
|
||||
street: editAddress?.street ?? "",
|
||||
city: editAddress?.city ?? "",
|
||||
state: editAddress?.state ?? "",
|
||||
postalCode: editAddress?.postalCode ?? "",
|
||||
country: editAddress?.country ?? "المملكة العربية السعودية",
|
||||
isPrimary: editAddress?.isPrimary ?? false,
|
||||
});
|
||||
const [errors, setErrors] = useState<ClientAddressFormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
useEffect(() => { firstRef.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 ClientAddressFormData) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||
if (errors[field as keyof ClientAddressFormErrors])
|
||||
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate(form);
|
||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
const ok = await onSubmit(form, isNew);
|
||||
setSaving(false);
|
||||
if (ok) onClose();
|
||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||
};
|
||||
|
||||
const inputStyle = (field: keyof ClientAddressFormErrors): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="addr-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 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",
|
||||
}}
|
||||
>
|
||||
{/* 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="addr-modal-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{isNew ? "عنوان جديد" : editAddress?.label}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
color: "var(--color-text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||
)}
|
||||
|
||||
{/* Label + isPrimary */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: "0.75rem",
|
||||
alignItems: "end",
|
||||
}}
|
||||
>
|
||||
<label style={S.label}>
|
||||
نوع العنوان *
|
||||
<select
|
||||
ref={firstRef}
|
||||
style={{ ...inputStyle("label"), cursor: "pointer" }}
|
||||
value={form.label}
|
||||
onChange={set("label")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر النوع</option>
|
||||
{LABEL_PRESETS.map((l) => (
|
||||
<option key={l} value={l}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.label && <span style={S.errorText}>{errors.label}</span>}
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
paddingBottom: 2,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPrimary}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, isPrimary: e.target.checked }))
|
||||
}
|
||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||
/>
|
||||
عنوان رئيسي
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Street */}
|
||||
<label style={S.label}>
|
||||
الشارع / العنوان التفصيلي *
|
||||
<input
|
||||
style={inputStyle("street")}
|
||||
value={form.street}
|
||||
onChange={set("street")}
|
||||
placeholder="شارع الملك فهد، مبنى 12"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.street && <span style={S.errorText}>{errors.street}</span>}
|
||||
</label>
|
||||
|
||||
{/* City + State */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
المدينة *
|
||||
<input
|
||||
style={inputStyle("city")}
|
||||
value={form.city}
|
||||
onChange={set("city")}
|
||||
placeholder="الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.city && <span style={S.errorText}>{errors.city}</span>}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
المنطقة *
|
||||
<input
|
||||
style={inputStyle("state")}
|
||||
value={form.state}
|
||||
onChange={set("state")}
|
||||
placeholder="منطقة الرياض"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.state && <span style={S.errorText}>{errors.state}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Postal code + Country */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<label style={S.label}>
|
||||
الرمز البريدي *
|
||||
<input
|
||||
style={inputStyle("postalCode")}
|
||||
value={form.postalCode}
|
||||
onChange={set("postalCode")}
|
||||
placeholder="11564"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.postalCode && (
|
||||
<span style={S.errorText}>{errors.postalCode}</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={S.label}>
|
||||
الدولة *
|
||||
<input
|
||||
style={inputStyle("country")}
|
||||
value={form.country}
|
||||
onChange={set("country")}
|
||||
placeholder="المملكة العربية السعودية"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.country && <span style={S.errorText}>{errors.country}</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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import type { Client, ClientFormData, ClientFormErrors } from "@/src/types/client";
|
||||
import {
|
||||
createClientSchema,
|
||||
updateClientSchema,
|
||||
CLIENT_TYPES,
|
||||
type CreateClientFormValues,
|
||||
type UpdateClientFormValues,
|
||||
} from "@/src/validations/client.validator";
|
||||
import type { Client } from "@/src/types/client";
|
||||
|
||||
// ── Fixed styles (same token system as UserFormModal) ──────────────────────
|
||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||
const S = {
|
||||
input: {
|
||||
width: "100%",
|
||||
@@ -33,83 +42,58 @@ const S = {
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const PHONE_RE = /^\+?[0-9]{10,15}$/;
|
||||
|
||||
function validate(data: ClientFormData): ClientFormErrors {
|
||||
const e: ClientFormErrors = {};
|
||||
if (!data.name.trim()) e.name = "اسم العميل مطلوب";
|
||||
if (!data.email.trim()) {
|
||||
e.email = "البريد الإلكتروني مطلوب";
|
||||
} else if (!EMAIL_RE.test(data.email)) {
|
||||
e.email = "صيغة البريد الإلكتروني غير صحيحة";
|
||||
}
|
||||
if (!data.phone.trim()) {
|
||||
e.phone = "رقم الهاتف مطلوب";
|
||||
} else if (!PHONE_RE.test(data.phone.replace(/\s/g, ""))) {
|
||||
e.phone = "رقم هاتف غير صالح (10–15 رقم)";
|
||||
}
|
||||
return e;
|
||||
}
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientFormModalProps {
|
||||
editClient: Client | null; // null = create mode, Client = edit mode
|
||||
editClient: Client | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ClientFormData, isNew: boolean) => Promise<boolean>;
|
||||
onSubmit: (
|
||||
data: CreateClientFormValues | UpdateClientFormValues,
|
||||
isNew: boolean
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function ClientFormModal({
|
||||
editClient,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ClientFormModalProps) {
|
||||
export function ClientFormModal({ editClient, onClose, onSubmit }: ClientFormModalProps) {
|
||||
const isNew = editClient === null;
|
||||
|
||||
const [form, setForm] = useState<ClientFormData>({
|
||||
name: editClient?.name ?? "",
|
||||
email: editClient?.email ?? "",
|
||||
phone: editClient?.phone ?? "",
|
||||
taxId: editClient?.taxId ?? "",
|
||||
notes: editClient?.notes ?? "",
|
||||
});
|
||||
const [errors, setErrors] = useState<ClientFormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
// FIX 1: was `Schema` (capital S) — now correctly `schema`
|
||||
const schema = isNew ? createClientSchema : updateClientSchema;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateClientFormValues | UpdateClientFormValues>({
|
||||
resolver: yupResolver(schema) as never, // FIX 1 applied here
|
||||
defaultValues: {
|
||||
name: editClient?.name ?? "",
|
||||
email: editClient?.email ?? "",
|
||||
phone: editClient?.phone ?? "",
|
||||
clientType: editClient?.clientType ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
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 ClientFormData) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setForm((p) => ({ ...p, [field]: e.target.value }));
|
||||
if (errors[field]) setErrors((p) => ({ ...p, [field]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate(form);
|
||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
const ok = await onSubmit(form, isNew);
|
||||
setSaving(false);
|
||||
if (ok) onClose();
|
||||
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||
const submitHandler = async (data: CreateClientFormValues | UpdateClientFormValues) => {
|
||||
const ok = await onSubmit(data, isNew);
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
};
|
||||
|
||||
const inputStyle = (field: keyof ClientFormErrors): React.CSSProperties => ({
|
||||
...S.input,
|
||||
...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
@@ -200,7 +184,7 @@ export function ClientFormModal({
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
@@ -209,26 +193,24 @@ export function ClientFormModal({
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||
{errors.name?.type === "manual" && (
|
||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<label style={S.label}>
|
||||
اسم العميل *
|
||||
اسم العميل {isNew && "*"}
|
||||
<input
|
||||
ref={firstInputRef}
|
||||
style={inputStyle("name")}
|
||||
value={form.name}
|
||||
onChange={set("name")}
|
||||
{...register("name")}
|
||||
style={withError(!!errors.name)}
|
||||
placeholder="شركة لوجي فلو للتوصيل"
|
||||
autoComplete="organization"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.name && <span style={S.errorText}>{errors.name}</span>}
|
||||
{errors.name && errors.name.type !== "manual" && (
|
||||
<span style={S.errorText}>{errors.name.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Email + Phone */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -237,63 +219,77 @@ export function ClientFormModal({
|
||||
}}
|
||||
>
|
||||
<label style={S.label}>
|
||||
البريد الإلكتروني *
|
||||
البريد الإلكتروني
|
||||
<input
|
||||
style={inputStyle("email")}
|
||||
{...register("email")}
|
||||
style={withError(!!errors.email)}
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set("email")}
|
||||
placeholder="info@company.sa"
|
||||
autoComplete="email"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.email && <span style={S.errorText}>{errors.email}</span>}
|
||||
{errors.email && (
|
||||
<span style={S.errorText}>{errors.email.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={S.label}>
|
||||
رقم الهاتف *
|
||||
رقم الهاتف {isNew && "*"}
|
||||
<input
|
||||
style={inputStyle("phone")}
|
||||
{...register("phone")}
|
||||
style={withError(!!errors.phone)}
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={set("phone")}
|
||||
placeholder="+966 5x xxx xxxx"
|
||||
placeholder="05xxxxxxxx"
|
||||
autoComplete="tel"
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.phone && <span style={S.errorText}>{errors.phone}</span>}
|
||||
{errors.phone && (
|
||||
<span style={S.errorText}>{errors.phone.message}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tax ID */}
|
||||
<label style={S.label}>
|
||||
الرقم الضريبي (اختياري)
|
||||
<input
|
||||
style={S.input}
|
||||
value={form.taxId}
|
||||
onChange={set("taxId")}
|
||||
placeholder="300xxxxxxxxx"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Notes */}
|
||||
<label style={S.label}>
|
||||
ملاحظات (اختياري)
|
||||
<textarea
|
||||
style={{
|
||||
...S.input,
|
||||
height: 80,
|
||||
padding: "0.5rem 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
value={form.notes}
|
||||
onChange={set("notes")}
|
||||
placeholder="أي معلومات إضافية عن العميل…"
|
||||
نوع العميل
|
||||
<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>
|
||||
|
||||
{/* Actions */}
|
||||
{!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",
|
||||
@@ -305,7 +301,7 @@ export function ClientFormModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.25rem",
|
||||
@@ -315,7 +311,7 @@ export function ClientFormModal({
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
@@ -323,27 +319,27 @@ export function ClientFormModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
height: 40,
|
||||
padding: "0 1.5rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: saving
|
||||
background: isSubmitting
|
||||
? "var(--color-brand-400)"
|
||||
: "var(--color-brand-600)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{saving && <Spinner size="sm" className="text-white" />}
|
||||
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
||||
{isSubmitting && <Spinner size="sm" className="text-white" />}
|
||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة العميل" : "حفظ التغييرات"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ClientTable — Enhanced version
|
||||
*
|
||||
* Changes from original:
|
||||
* 1. Clicking a row now opens an inline ClientDetailPanel (slide-down) showing full client info.
|
||||
* 2. "Addresses" link inside the detail panel navigates to /dashboard/clients/[clientId]/addresses.
|
||||
* 3. The row-level onClick still calls onManageAddresses (kept for backwards-compatibility),
|
||||
* but the primary UX is now the expandable detail panel.
|
||||
* 4. Active row is highlighted with a brand-tinted left border.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import type { Client } from "@/src/types/client";
|
||||
|
||||
// ── Address count badge ────────────────────────────────────────────────────
|
||||
function AddressBadge({ count }: { count: number }) {
|
||||
function AddressBadge({ count, onClick }: { count: number; onClick: () => void }) {
|
||||
const hasAddr = count > 0;
|
||||
return (
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
title="إدارة العناوين"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: hasAddr ? "1px solid #BFDBFE" : "1px solid var(--color-border)",
|
||||
background: hasAddr ? "#EFF6FF" : "var(--color-surface-muted)",
|
||||
@@ -18,10 +34,17 @@ function AddressBadge({ count }: { count: number }) {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: hasAddr ? "#1D4ED8" : "var(--color-text-muted)",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
transition: "opacity 150ms",
|
||||
}}
|
||||
>
|
||||
{count} {count === 1 ? "عنوان" : "عناوين"}
|
||||
</span>
|
||||
{/* Small arrow icon to hint navigation */}
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +79,7 @@ function StatusBadge({ active }: { active?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icon button (mirrors UserTable's IconBtn) ──────────────────────────────
|
||||
// ── Icon button ────────────────────────────────────────────────────────────
|
||||
function IconBtn({
|
||||
onClick,
|
||||
title,
|
||||
@@ -97,6 +120,233 @@ function IconBtn({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client detail panel (inline expandable) ────────────────────────────────
|
||||
/**
|
||||
* ClientDetailPanel — shown below the clicked row.
|
||||
* Displays all client fields (name, email, phone, taxId, notes, createdAt)
|
||||
* and provides a direct link to the addresses sub-page.
|
||||
*
|
||||
* @param client The client whose details are rendered.
|
||||
* @param onGoAddresses Callback that navigates to /dashboard/clients/[id]/addresses.
|
||||
* @param onEdit Opens the edit modal for this client.
|
||||
*/
|
||||
function ClientDetailPanel({
|
||||
client,
|
||||
onGoAddresses,
|
||||
onEdit,
|
||||
}: {
|
||||
client: Client;
|
||||
onGoAddresses: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const infoItem = (label: string, value: React.ReactNode) => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.15em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-primary)",
|
||||
fontWeight: 500,
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{value || <span style={{ color: "var(--color-text-hint)" }}>—</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
/* Slide-down visual treatment: left accent border in brand blue */
|
||||
borderRight: "3px solid var(--color-brand-600)",
|
||||
background: "linear-gradient(135deg, #EFF6FF 0%, #F0F9FF 100%)",
|
||||
borderBottom: "1px solid #BFDBFE",
|
||||
padding: "1.25rem 1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
animation: "detailFadeIn 180ms ease",
|
||||
}}
|
||||
>
|
||||
{/* ── Top row: name + quick actions ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{client.name}
|
||||
</h3>
|
||||
{client.taxId && (
|
||||
<code
|
||||
style={{
|
||||
marginTop: 2,
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
background: "#DBEAFE",
|
||||
border: "1px solid #BFDBFE",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: "0.1rem 0.4rem",
|
||||
color: "#1E40AF",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
{client.taxId}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
|
||||
{/* Edit client */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 0.875rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid #BFDBFE",
|
||||
background: "#EFF6FF",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#1D4ED8",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل البيانات
|
||||
</button>
|
||||
|
||||
{/*
|
||||
* "Addresses" button — KEY FEATURE:
|
||||
* Navigates to /dashboard/clients/[clientId]/addresses
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onGoAddresses(); }}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 0.875rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: "var(--color-brand-600)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
boxShadow: "0 1px 4px rgba(37,99,235,.3)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
العناوين
|
||||
{client.addresses?.length !== undefined && (
|
||||
<span
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.25)",
|
||||
borderRadius: "var(--radius-full)",
|
||||
padding: "0 5px",
|
||||
fontSize: 10,
|
||||
minWidth: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{client.addresses.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Info grid ── */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: "1rem",
|
||||
paddingTop: "0.75rem",
|
||||
borderTop: "1px solid #BFDBFE",
|
||||
}}
|
||||
>
|
||||
{infoItem(
|
||||
"البريد الإلكتروني",
|
||||
<a
|
||||
href={`mailto:${client.email}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ color: "#2563EB", textDecoration: "none" }}
|
||||
>
|
||||
{client.email}
|
||||
</a>,
|
||||
)}
|
||||
{infoItem("الهاتف", client.phone)}
|
||||
{infoItem(
|
||||
"تاريخ الإضافة",
|
||||
new Date(client.createdAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
)}
|
||||
{infoItem(
|
||||
"آخر تحديث",
|
||||
new Date(client.updatedAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
)}
|
||||
{client.notes && infoItem("ملاحظات", client.notes)}
|
||||
</div>
|
||||
|
||||
{/* Keyframe animation injected via a style tag (scoped) */}
|
||||
<style>{`
|
||||
@keyframes detailFadeIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared card & header styles ────────────────────────────────────────────
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: "var(--radius-xl)",
|
||||
@@ -119,14 +369,14 @@ const thStyle: React.CSSProperties = {
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface ClientTableProps {
|
||||
clients: Client[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (client: Client) => void;
|
||||
onDelete: (client: Client) => void;
|
||||
onAddFirst: () => void;
|
||||
clients: Client[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (client: Client) => void;
|
||||
onDelete: (client: Client) => void;
|
||||
onAddFirst: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
/** Navigate to address management for a client */
|
||||
onManageAddresses: (client: Client) => void;
|
||||
@@ -145,6 +395,17 @@ export function ClientTable({
|
||||
onPageChange,
|
||||
onManageAddresses,
|
||||
}: ClientTableProps) {
|
||||
/**
|
||||
* expandedId — tracks which client row is currently expanded.
|
||||
* Clicking the same row again collapses it (toggle behaviour).
|
||||
*/
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const handleRowClick = (client: Client) => {
|
||||
// Toggle: collapse if already open, expand otherwise
|
||||
setExpandedId((prev) => (prev === client.id ? null : client.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* Column headers */}
|
||||
@@ -209,138 +470,202 @@ export function ClientTable({
|
||||
) : (
|
||||
/* Data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{clients.map((c, i) => (
|
||||
<li
|
||||
key={c.id}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background:
|
||||
i % 2 !== 0
|
||||
? "var(--color-surface-muted)"
|
||||
: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onManageAddresses(c)}
|
||||
title={`إدارة عناوين ${c.name}`}
|
||||
>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<p
|
||||
{clients.map((c, i) => {
|
||||
const isExpanded = expandedId === c.id;
|
||||
return (
|
||||
<li key={c.id}>
|
||||
{/* ── Main row ── */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${c.name} — انقر لعرض التفاصيل`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleRowClick(c);
|
||||
}}
|
||||
onClick={() => handleRowClick(c)}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1.5fr 1fr 1fr 80px",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: isExpanded
|
||||
? "none"
|
||||
: "1px solid var(--color-border)",
|
||||
/* Highlight the expanded row with a brand-tinted background */
|
||||
background: isExpanded
|
||||
? "#EFF6FF"
|
||||
: i % 2 !== 0
|
||||
? "var(--color-surface-muted)"
|
||||
: "transparent",
|
||||
/* Left accent border when expanded (RTL: border-right in visual) */
|
||||
borderRight: isExpanded
|
||||
? "3px solid var(--color-brand-600)"
|
||||
: "3px solid transparent",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
transition: "background 120ms, border-color 120ms",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</p>
|
||||
{c.taxId && (
|
||||
<p
|
||||
{/* Name */}
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
{/* Chevron indicator — rotates when expanded */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--color-text-muted)",
|
||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||
transition: "transform 200ms",
|
||||
}}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</p>
|
||||
{c.taxId && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{c.taxId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<span
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{c.email}
|
||||
</span>
|
||||
|
||||
{/* Phone */}
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{c.phone}
|
||||
</span>
|
||||
|
||||
{/*
|
||||
* Address count badge — clicking it navigates directly to
|
||||
* /dashboard/clients/[clientId]/addresses (bypasses the detail panel).
|
||||
*/}
|
||||
<AddressBadge
|
||||
count={c.addresses?.length ?? 0}
|
||||
onClick={() => onManageAddresses(c)}
|
||||
/>
|
||||
|
||||
{/* Created at */}
|
||||
<span
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{c.taxId}
|
||||
</p>
|
||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Row action buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconBtn
|
||||
title={`تعديل ${c.name}`}
|
||||
color="#1D4ED8"
|
||||
bg="#EFF6FF"
|
||||
borderColor="#BFDBFE"
|
||||
onClick={() => onEdit(c)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
title={`حذف ${c.name}`}
|
||||
color="#DC2626"
|
||||
bg="#FEF2F2"
|
||||
borderColor="#FECACA"
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* ── Expandable detail panel ──
|
||||
* Rendered inline below the row when isExpanded is true.
|
||||
* Contains full client metadata + "العناوين" navigation button.
|
||||
*
|
||||
* TEST: Click any row → panel slides in with client details.
|
||||
* Click "العناوين" → router.push to /dashboard/clients/[id]/addresses.
|
||||
* Click same row again → panel collapses.
|
||||
*/}
|
||||
{isExpanded && (
|
||||
<ClientDetailPanel
|
||||
client={c}
|
||||
onGoAddresses={() => onManageAddresses(c)}
|
||||
onEdit={() => onEdit(c)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{c.email}
|
||||
</span>
|
||||
|
||||
{/* Phone */}
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{c.phone}
|
||||
</span>
|
||||
|
||||
{/* Address count */}
|
||||
<AddressBadge count={c.addresses?.length ?? 0} />
|
||||
|
||||
{/* Created at */}
|
||||
<span
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // prevent row navigation
|
||||
>
|
||||
<IconBtn
|
||||
title={`تعديل ${c.name}`}
|
||||
color="#1D4ED8"
|
||||
bg="#EFF6FF"
|
||||
borderColor="#BFDBFE"
|
||||
onClick={() => onEdit(c)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
title={`حذف ${c.name}`}
|
||||
color="#DC2626"
|
||||
bg="#FEF2F2"
|
||||
borderColor="#FECACA"
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,64 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Notification } from "@/src/hooks/useClients";
|
||||
|
||||
interface ToastProps {
|
||||
notification: Notification | null;
|
||||
}
|
||||
|
||||
export function Toast({ notification }: ToastProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (notification) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
const t = setTimeout(() => setVisible(false), 300);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [notification]);
|
||||
|
||||
if (!visible && !notification) return null;
|
||||
|
||||
const isSuccess = notification?.type === "success";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
left: "50%",
|
||||
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
|
||||
zIndex: 9999,
|
||||
transition: "transform 250ms ease, opacity 250ms ease",
|
||||
opacity: notification ? 1 : 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "0.75rem 1.25rem",
|
||||
borderRadius: "var(--radius-full)",
|
||||
background: isSuccess ? "#065F46" : "#7F1D1D",
|
||||
color: "#FFFFFF",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
|
||||
maxWidth: "90vw",
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
|
||||
<span>{notification?.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Re-export the canonical implementation so existing imports keep working.
|
||||
export { Toast } from "@/src/Components/UI";
|
||||
export type { ToastNotification as Notification } from "@/src/Components/UI";
|
||||
@@ -1,5 +1,18 @@
|
||||
export { ClientFormModal } from "./Clientformmodal";
|
||||
export { ClientTable } from "./Clienttable";
|
||||
export { AddressFormModal } from "./Addressformmodal";
|
||||
/**
|
||||
* src/Components/Client/index.ts
|
||||
*
|
||||
* Barrel exports for all Client-domain components.
|
||||
*
|
||||
* NOTE: Toast is re-exported here for backwards-compatibility, but it now
|
||||
* delegates to src/Components/UI/Toast. Prefer importing Toast directly
|
||||
* from "@/src/Components/UI" in new code.
|
||||
*/
|
||||
|
||||
export { ClientFormModal } from "./Clientformmodal";
|
||||
export { ClientTable } from "./Clienttable";
|
||||
export { AddressFormModal } from "../Client_Adress/Addressformmodal";
|
||||
export { DeleteConfirmModal } from "./Deleteconfirmmodal";
|
||||
|
||||
// Toast: re-exported from Client/Toast, which itself re-exports UI/Toast.
|
||||
// Maintains backwards-compat for any consumer importing from "@/src/Components/Client".
|
||||
export { Toast } from "./Toast";
|
||||
556
src/Components/Client_Adress/Addressformmodal.tsx
Normal file
556
src/Components/Client_Adress/Addressformmodal.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Spinner } from "../UI";
|
||||
import {
|
||||
createAddressSchema,
|
||||
updateAddressSchema,
|
||||
type CreateAddressFormValues,
|
||||
type UpdateAddressFormValues,
|
||||
} from "@/src/validations/client_address.validator";
|
||||
import type { ClientAddress } from "@/src/types/client";
|
||||
|
||||
// ── 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,
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-muted)",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase" as const,
|
||||
paddingBottom: "0.25rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
// FIX 2: Use only the `border` shorthand in both branches — no borderColor/border conflict
|
||||
const withError = (hasError: boolean): React.CSSProperties => ({
|
||||
...S.input,
|
||||
border: hasError
|
||||
? "1px solid var(--color-danger)"
|
||||
: "1px solid var(--color-border)",
|
||||
background: hasError ? "#FEF2F2" : S.input.background,
|
||||
});
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────
|
||||
interface AddressFormModalProps {
|
||||
editAddress: ClientAddress | null;
|
||||
onClose: () => void;
|
||||
// FIX: removed the "isNew" second argument. The parent (page.tsx) now
|
||||
// figures out create-vs-update itself from addrFormTarget, so it no longer
|
||||
// needs us to tell it isNew. This avoids the two values (isNew here vs
|
||||
// addrFormTarget there) ever disagreeing with each other.
|
||||
onSubmit: (
|
||||
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
export function AddressFormModal({ editAddress, onClose, onSubmit }: AddressFormModalProps) {
|
||||
const isNew = editAddress === null;
|
||||
|
||||
// NOTE: "schema" variable removed — resolver now picks createAddressSchema
|
||||
// or updateAddressSchema directly inline (see useForm below).
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateAddressFormValues | UpdateAddressFormValues>({
|
||||
// FIX: "as never" was hiding all type errors on resolver/errors.
|
||||
// yupResolver only works with ONE concrete schema type, not a union,
|
||||
// so we still need a cast — but cast to the real Resolver type instead
|
||||
// of "never", so formState.errors keeps proper typing.
|
||||
resolver: (isNew ? yupResolver(createAddressSchema) : yupResolver(updateAddressSchema)) as Resolver<
|
||||
CreateAddressFormValues | UpdateAddressFormValues
|
||||
>,
|
||||
defaultValues: {
|
||||
label: editAddress?.label ?? "",
|
||||
branchName: editAddress?.branchName ?? "",
|
||||
isPrimary: editAddress?.isPrimary ?? false,
|
||||
|
||||
contactPerson: {
|
||||
name: editAddress?.contactPerson?.name ?? "",
|
||||
phone: editAddress?.contactPerson?.phone ?? "",
|
||||
},
|
||||
|
||||
details: {
|
||||
country: editAddress?.details?.country ?? "SA",
|
||||
city: editAddress?.details?.city ?? "",
|
||||
state: editAddress?.details?.state ?? "",
|
||||
district: editAddress?.details?.district ?? "",
|
||||
street: editAddress?.details?.street ?? "",
|
||||
buildingNo: editAddress?.details?.buildingNo ?? "",
|
||||
unitNo: editAddress?.details?.unitNo ?? "",
|
||||
additionalNo: editAddress?.details?.additionalNo ?? "",
|
||||
zipCode: editAddress?.details?.zipCode ?? "",
|
||||
apartment: editAddress?.details?.apartment ?? "",
|
||||
},
|
||||
|
||||
location: {
|
||||
coordinates: editAddress?.location?.coordinates ?? [0, 0],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const detailsErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.details ?? {};
|
||||
const contactErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.contactPerson ?? {};
|
||||
const locationErr = (errors as never as Record<string, Record<string, { message?: string }>>)?.location ?? {};
|
||||
|
||||
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 ok = await onSubmit(data); // FIX: no more isNew arg, see prop type above
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError("label", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="addr-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 560,
|
||||
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 — pinned */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
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="addr-modal-title"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{isNew ? "عنوان جديد" : editAddress?.label}
|
||||
</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>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
<form
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{errors.label?.type === "manual" && (
|
||||
<Alert type="error" message={errors.label.message ?? ""} onClose={() => {}} />
|
||||
)}
|
||||
|
||||
{/* Address Meta */}
|
||||
<p style={S.sectionTitle}>بيانات العنوان</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: "0.75rem",
|
||||
alignItems: "end",
|
||||
}}
|
||||
>
|
||||
{/* FIX 3: Replaced <select> with a free-text <input> */}
|
||||
<label style={S.label}>
|
||||
نوع العنوان
|
||||
<input
|
||||
{...register("label")}
|
||||
style={withError(!!errors.label && errors.label.type !== "manual")}
|
||||
placeholder="مقترح: منزل / مكتب"
|
||||
dir="rtl"
|
||||
/>
|
||||
{errors.label && errors.label.type !== "manual" && (
|
||||
<span style={S.errorText}>{errors.label.message}</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
paddingBottom: 2,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("isPrimary")}
|
||||
style={{ width: 14, height: 14, cursor: "pointer" }}
|
||||
/>
|
||||
عنوان رئيسي
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={S.label}>
|
||||
اسم الفرع (اختياري)
|
||||
<input
|
||||
{...register("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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
"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 { tripService } from "@/src/services/trip.service";
|
||||
import {
|
||||
createOrderSchema,
|
||||
updateOrderSchema,
|
||||
} from "@/src/validations/order.validation";
|
||||
import type { ValidationError } from "yup";
|
||||
import type {
|
||||
Order,
|
||||
@@ -17,7 +19,7 @@ import type {
|
||||
PaymentStatus,
|
||||
} from "@/src/types/order";
|
||||
import type { Client } from "@/src/types/client";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
import type { OrderSchemaErrors } from "@/src/validations/order.validation";
|
||||
|
||||
// ── Shared input / label styles — verbatim from DriverFormModal.tsx ──────────
|
||||
@@ -91,84 +93,119 @@ interface OrderFormModalProps {
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalProps) {
|
||||
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);
|
||||
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(
|
||||
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(
|
||||
const [subTotal, setSubTotal] = useState(
|
||||
editOrder?.subTotal != null ? String(editOrder.subTotal) : "",
|
||||
);
|
||||
const [vatRate, setVatRate] = useState(
|
||||
const [vatRate, setVatRate] = useState(
|
||||
editOrder?.vatRate != null ? String(editOrder.vatRate) : "",
|
||||
);
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | "">(
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | "">(
|
||||
editOrder?.paymentMethod ?? "",
|
||||
);
|
||||
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus | "">(
|
||||
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 ?? "");
|
||||
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(
|
||||
const [delLng, setDelLng] = useState(
|
||||
editOrder?.deliveryAddress?.location?.coordinates?.[0] != null
|
||||
? String(editOrder.deliveryAddress!.location!.coordinates![0])
|
||||
: "",
|
||||
);
|
||||
const [delLat, setDelLat] = useState(
|
||||
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">(
|
||||
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 ?? "");
|
||||
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(
|
||||
const [pkpLng, setPkpLng] = useState(
|
||||
editOrder?.pickupAddress?.location?.coordinates?.[0] != null
|
||||
? String(editOrder.pickupAddress!.location!.coordinates![0])
|
||||
: "",
|
||||
);
|
||||
const [pkpLat, setPkpLat] = useState(
|
||||
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 [errors, setErrors] = useState<OrderSchemaErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState("");
|
||||
const firstRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -185,7 +222,10 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
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 tripRes = await tripService.getAll(
|
||||
{ page: 1, limit: 100 },
|
||||
token,
|
||||
);
|
||||
const tripPayload = (tripRes as any).data ?? tripRes;
|
||||
const tripList: Trip[] = tripPayload.data ?? [];
|
||||
|
||||
@@ -201,19 +241,27 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
// links — but now they also see a message explaining why.
|
||||
console.error("OrderFormModal: failed to load clients/trips", err);
|
||||
if (!cancelled) {
|
||||
setApiError("تعذّر تحميل قوائم العملاء والرحلات. تحقق من اتصالك وحاول إعادة فتح النافذة.");
|
||||
setApiError(
|
||||
"تعذّر تحميل قوائم العملاء والرحلات. تحقق من اتصالك وحاول إعادة فتح النافذة.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setRelLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Keyboard / focus setup ────────────────────────────────────────────────
|
||||
useEffect(() => { firstRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
firstRef.current?.focus();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
@@ -221,14 +269,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
// ── Dynamic input error style ─────────────────────────────────────────────
|
||||
const inputStyle = (field: keyof OrderSchemaErrors): React.CSSProperties => ({
|
||||
...inputBase,
|
||||
border: errors[field]
|
||||
? "1px solid var(--color-danger)"
|
||||
: inputBase.border,
|
||||
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 })),
|
||||
(field: keyof OrderSchemaErrors) =>
|
||||
setErrors((p) => ({ ...p, [field]: undefined })),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -244,11 +291,11 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
recipientName,
|
||||
recipientPhone,
|
||||
quantity: quantity ? Number(quantity) : undefined,
|
||||
weight: weight ? Number(weight) : undefined,
|
||||
weight: weight ? Number(weight) : undefined,
|
||||
subTotal: subTotal ? Number(subTotal) : undefined,
|
||||
vatRate: vatRate ? Number(vatRate) : undefined,
|
||||
paymentMethod: paymentMethod || undefined,
|
||||
paymentStatus: paymentStatus || undefined,
|
||||
vatRate: vatRate ? Number(vatRate) : undefined,
|
||||
paymentMethod: paymentMethod || undefined,
|
||||
paymentStatus: paymentStatus || undefined,
|
||||
type: type || undefined,
|
||||
},
|
||||
{ abortEarly: false },
|
||||
@@ -263,11 +310,26 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
setErrors(bag);
|
||||
return false;
|
||||
}
|
||||
}, [isNew, tripId, clientId, shipmentNumber, recipientName, recipientPhone,
|
||||
quantity, weight, subTotal, vatRate, paymentMethod, paymentStatus, type]);
|
||||
}, [
|
||||
isNew,
|
||||
tripId,
|
||||
clientId,
|
||||
shipmentNumber,
|
||||
recipientName,
|
||||
recipientPhone,
|
||||
quantity,
|
||||
weight,
|
||||
subTotal,
|
||||
vatRate,
|
||||
paymentMethod,
|
||||
paymentStatus,
|
||||
type,
|
||||
]);
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
console.log("her");
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const valid = await runValidation();
|
||||
@@ -289,21 +351,21 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
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 (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 }),
|
||||
...(delCity && { city: delCity }),
|
||||
...(delDistrict && { district: delDistrict }),
|
||||
...(delStreet && { street: delStreet }),
|
||||
...(delBuildingNo && { buildingNo: delBuildingNo }),
|
||||
...(delUnitNo && { unitNo: delUnitNo }),
|
||||
...(delZipCode && { zipCode: delZipCode }),
|
||||
...(delUnitNo && { unitNo: delUnitNo }),
|
||||
...(delZipCode && { zipCode: delZipCode }),
|
||||
};
|
||||
// coordinates are always included when provided (required by backend GeoJSON schema)
|
||||
const delCoordinates =
|
||||
@@ -323,12 +385,12 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
payload.pickupAddressId = pickupAddressId.trim();
|
||||
} else {
|
||||
const pkpDetails = {
|
||||
...(pkpCity && { city: pkpCity }),
|
||||
...(pkpDistrict && { district: pkpDistrict }),
|
||||
...(pkpStreet && { street: pkpStreet }),
|
||||
...(pkpCity && { city: pkpCity }),
|
||||
...(pkpDistrict && { district: pkpDistrict }),
|
||||
...(pkpStreet && { street: pkpStreet }),
|
||||
...(pkpBuildingNo && { buildingNo: pkpBuildingNo }),
|
||||
...(pkpUnitNo && { unitNo: pkpUnitNo }),
|
||||
...(pkpZipCode && { zipCode: pkpZipCode }),
|
||||
...(pkpUnitNo && { unitNo: pkpUnitNo }),
|
||||
...(pkpZipCode && { zipCode: pkpZipCode }),
|
||||
};
|
||||
const pkpCoordinates =
|
||||
pkpLng.trim() && pkpLat.trim()
|
||||
@@ -343,12 +405,14 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setSaving(true);
|
||||
setApiError("");
|
||||
|
||||
try {
|
||||
const ok = await onSubmit(payload as unknown as CreateOrderPayload, isNew);
|
||||
const ok = await onSubmit(
|
||||
payload as unknown as CreateOrderPayload,
|
||||
isNew,
|
||||
);
|
||||
if (ok) {
|
||||
onClose();
|
||||
} else {
|
||||
@@ -356,7 +420,9 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -369,57 +435,88 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-modal-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
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",
|
||||
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,
|
||||
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",
|
||||
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
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p style={{
|
||||
fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase",
|
||||
color: "#2563EB", fontWeight: 600, margin: 0,
|
||||
}}>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.3em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إنشاء طلب" : "تعديل طلب"}
|
||||
</p>
|
||||
<h2
|
||||
id="order-modal-title"
|
||||
style={{
|
||||
fontSize: 17, fontWeight: 700,
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: "4px 0 0", fontFamily: "var(--font-mono)",
|
||||
margin: "4px 0 0",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{isNew ? "طلب جديد" : editOrder?.shipmentNumber}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button" onClick={onClose} aria-label="إغلاق"
|
||||
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",
|
||||
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",
|
||||
}}
|
||||
>
|
||||
×
|
||||
@@ -432,20 +529,32 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex", flexDirection: "column", gap: "1rem",
|
||||
maxHeight: "75vh", overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
maxHeight: "75vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
dir="rtl"
|
||||
>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError} onClose={() => setApiError("")} />
|
||||
<Alert
|
||||
type="error"
|
||||
message={apiError}
|
||||
onClose={() => setApiError("")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Section: Shipment & Recipient ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات الشحنة والمستلم</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Shipment number — required */}
|
||||
<label style={labelStyle}>
|
||||
رقم الشحنة *
|
||||
@@ -498,9 +607,19 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<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">
|
||||
<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>
|
||||
@@ -576,9 +695,19 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<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">
|
||||
<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>
|
||||
@@ -590,7 +719,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
{/* ── Section: Shipment details ── */}
|
||||
<p style={sectionHeadingStyle}>تفاصيل الشحنة</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Type — optional */}
|
||||
<label style={labelStyle}>
|
||||
نوع الشحنة
|
||||
@@ -642,7 +777,13 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
{/* ── Section: Payment ── */}
|
||||
<p style={sectionHeadingStyle}>بيانات الدفع</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Subtotal — optional */}
|
||||
<label style={labelStyle}>
|
||||
الإجمالي الفرعي
|
||||
@@ -692,7 +833,9 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<select
|
||||
style={{ ...inputBase, cursor: "pointer" }}
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value as PaymentMethod | "")}
|
||||
onChange={(e) =>
|
||||
setPaymentMethod(e.target.value as PaymentMethod | "")
|
||||
}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر الطريقة</option>
|
||||
@@ -726,36 +869,78 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
|
||||
{/* ── Section: Delivery Address ── */}
|
||||
<p style={sectionHeadingStyle}>عنوان التسليم</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={delZipCode}
|
||||
onChange={(e) => setDelZipCode(e.target.value)}
|
||||
placeholder="12345"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
{/* GeoJSON coordinates — required by backend */}
|
||||
<label style={labelStyle}>
|
||||
@@ -788,20 +973,31 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<p style={sectionHeadingStyle}>عنوان الاستلام</p>
|
||||
|
||||
{/* Toggle: existing ID or new address */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.25rem" }}>
|
||||
<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",
|
||||
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)",
|
||||
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)" : "عنوان جديد"}
|
||||
@@ -822,36 +1018,78 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<input
|
||||
style={inputBase}
|
||||
value={pkpZipCode}
|
||||
onChange={(e) => setPkpZipCode(e.target.value)}
|
||||
placeholder="23456"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
{/* GeoJSON coordinates — optional for pickup */}
|
||||
<label style={labelStyle}>
|
||||
@@ -884,20 +1122,27 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
)}
|
||||
|
||||
{/* ── 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}
|
||||
style={{
|
||||
height: 40, padding: "0 1.25rem",
|
||||
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)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
@@ -907,14 +1152,22 @@ export function OrderFormModal({ editOrder, onClose, onSubmit }: OrderFormModalP
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
onClick={() => console.log("BUTTON CLICKED DIRECTLY")}
|
||||
style={{
|
||||
height: 40, padding: "0 1.5rem",
|
||||
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",
|
||||
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,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { clientAddressService } from "@/src/services/clientAddress.service";
|
||||
import type {
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
AddressTableAction,
|
||||
AddressTableState,
|
||||
} from "@/src/types/client";
|
||||
CreateAddressFormValues,
|
||||
UpdateAddressFormValues,
|
||||
} from "@/src/validations/client_address.validator";
|
||||
import { AddressTableAction, AddressTableState } from "../types/client";
|
||||
|
||||
// ── Notification ───────────────────────────────────────────────────────────
|
||||
export interface Notification {
|
||||
@@ -90,7 +89,7 @@ export function useClientAddresses(clientId: string) {
|
||||
|
||||
// ── CREATE ───────────────────────────────────────────────────────────────
|
||||
const createAddress = useCallback(
|
||||
async (data: ClientAddressFormData): Promise<boolean> => {
|
||||
async (data: CreateAddressFormValues): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientAddressService.create(clientId, data, token);
|
||||
@@ -110,7 +109,7 @@ export function useClientAddresses(clientId: string) {
|
||||
|
||||
// ── UPDATE ───────────────────────────────────────────────────────────────
|
||||
const updateAddress = useCallback(
|
||||
async (id: string, data: ClientAddressFormData): Promise<boolean> => {
|
||||
async (id: string, data: UpdateAddressFormValues): Promise<boolean> => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await clientAddressService.update(clientId, id, data, token);
|
||||
|
||||
@@ -1,49 +1,86 @@
|
||||
// Mirrors user.service.ts pattern: explicit token param, buildPayload helper.
|
||||
import { get, post, put, patch, del } from "./api";
|
||||
import type { ApiResponse, ClientAddress } from "@/src/types/client";
|
||||
import type {
|
||||
ApiResponse,
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
} from "@/src/types/client";
|
||||
CreateAddressFormValues,
|
||||
UpdateAddressFormValues,
|
||||
} from "@/src/validations/client_address.validator";
|
||||
|
||||
const base = (clientId: string) => `client/${clientId}/addresses`;
|
||||
// FIX: if clientId is undefined/empty, this used to silently build a broken
|
||||
// URL like "client/undefined/addresses" which 404s with no clear error.
|
||||
// Now it throws a clear error pointing at the real problem (missing clientId).
|
||||
const base = (clientId: string) => {
|
||||
if (!clientId) {
|
||||
throw new Error("clientAddressService: clientId is required but was empty/undefined. Check useParams() key matches your route folder name.");
|
||||
}
|
||||
return `client/${clientId}/addresses`;
|
||||
};
|
||||
|
||||
// FIX: same idea as base() above — fail clearly if addressId is missing,
|
||||
// instead of building "client/123/addresses/undefined"
|
||||
const withAddressId = (clientId: string, addressId: string) => {
|
||||
if (!addressId) {
|
||||
throw new Error("clientAddressService: addressId is required but was empty/undefined.");
|
||||
}
|
||||
return `${base(clientId)}/${addressId}`;
|
||||
};
|
||||
|
||||
export const clientAddressService = {
|
||||
/** جلب جميع عناوين عميل معين */
|
||||
/** Get all addresses for a client */
|
||||
getAll: (clientId: string, token: string | null) =>
|
||||
get<ApiResponse<ClientAddress[]>>(base(clientId), token),
|
||||
|
||||
/** جلب عنوان واحد بالـ ID */
|
||||
/** Get a single address by id */
|
||||
getById: (clientId: string, addressId: string, token: string | null) =>
|
||||
get<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}`, token),
|
||||
get<ApiResponse<ClientAddress>>(withAddressId(clientId, addressId), token),
|
||||
|
||||
/** إضافة عنوان جديد لعميل */
|
||||
create: (clientId: string, data: ClientAddressFormData, token: string | null) =>
|
||||
post<ApiResponse<ClientAddress>>(base(clientId), buildPayload(clientId, data), token),
|
||||
/** Create a new address */
|
||||
create: (
|
||||
clientId: string,
|
||||
data: CreateAddressFormValues,
|
||||
token: string | null
|
||||
) =>
|
||||
post<ApiResponse<ClientAddress>>(base(clientId), buildPayload(data), token),
|
||||
|
||||
/** تعديل عنوان موجود */
|
||||
update: (clientId: string, addressId: string, data: ClientAddressFormData, token: string | null) =>
|
||||
put<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}`, buildPayload(clientId, data), token),
|
||||
/** Update an existing address */
|
||||
update: (
|
||||
clientId: string,
|
||||
addressId: string,
|
||||
data: UpdateAddressFormValues,
|
||||
token: string | null
|
||||
) =>
|
||||
put<ApiResponse<ClientAddress>>(
|
||||
withAddressId(clientId, addressId),
|
||||
buildPayload(data),
|
||||
token
|
||||
),
|
||||
|
||||
/** حذف عنوان */
|
||||
/** Delete an address */
|
||||
delete: (clientId: string, addressId: string, token: string | null) =>
|
||||
del<void>(`${base(clientId)}/${addressId}`, token),
|
||||
del<void>(withAddressId(clientId, addressId), token),
|
||||
|
||||
/** تعيين عنوان كعنوان رئيسي — الباكند يُلغي الاختيار عن الباقين */
|
||||
/** Promote an address to primary (backend demotes all others) */
|
||||
setPrimary: (clientId: string, addressId: string, token: string | null) =>
|
||||
patch<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}/primary`, {}, token),
|
||||
patch<ApiResponse<ClientAddress>>(
|
||||
`${withAddressId(clientId, addressId)}/primary`,
|
||||
{},
|
||||
token
|
||||
),
|
||||
};
|
||||
|
||||
/** تحويل بيانات النموذج إلى payload */
|
||||
function buildPayload(clientId: string, data: ClientAddressFormData): Record<string, unknown> {
|
||||
/**
|
||||
* Maps the validated form shape (nested) directly to the API payload.
|
||||
* FIX 1: No more .trim() calls on flat fields that no longer exist —
|
||||
* the validator already trims every string field before we reach here.
|
||||
*/
|
||||
function buildPayload(
|
||||
data: CreateAddressFormValues | UpdateAddressFormValues
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
clientId,
|
||||
label: data.label.trim(),
|
||||
street: data.street.trim(),
|
||||
city: data.city.trim(),
|
||||
state: data.state.trim(),
|
||||
postalCode: data.postalCode.trim(),
|
||||
country: data.country.trim(),
|
||||
isPrimary: data.isPrimary,
|
||||
label: data.label,
|
||||
branchName: data.branchName,
|
||||
isPrimary: data.isPrimary,
|
||||
contactPerson: data.contactPerson,
|
||||
details: data.details,
|
||||
location: data.location,
|
||||
};
|
||||
}
|
||||
@@ -12,16 +12,47 @@ export interface Client {
|
||||
addresses?: ClientAddress[];
|
||||
}
|
||||
|
||||
// FIX: this interface was flat (street, city, state... directly on the object)
|
||||
// but the real API / form / validator use a NESTED shape (details, contactPerson,
|
||||
// location). Updated to match what Addressformmodal.tsx actually reads
|
||||
// (editAddress?.details?.city, editAddress?.contactPerson?.phone, etc).
|
||||
export interface ClientAddress {
|
||||
id: string;
|
||||
clientId: string;
|
||||
label: string; // e.g. "Billing", "Shipping", "HQ"
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
|
||||
// was missing before — needed because the form reads editAddress?.branchName
|
||||
branchName: string | null;
|
||||
|
||||
// was missing before — needed because the form reads editAddress?.contactPerson?.name/phone
|
||||
contactPerson?: {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
// was flat (street/city/state/postalCode/country) — now nested under "details"
|
||||
// to match details.city, details.street, etc. used in the form and validator
|
||||
details: {
|
||||
country: string;
|
||||
city: string;
|
||||
state?: string;
|
||||
district?: string;
|
||||
street: string;
|
||||
buildingNo?: string;
|
||||
unitNo?: string;
|
||||
additionalNo?: string;
|
||||
zipCode?: string;
|
||||
apartment?: string;
|
||||
};
|
||||
|
||||
// was missing before — needed because the form reads
|
||||
// editAddress?.location?.coordinates
|
||||
location: {
|
||||
coordinates: [number, number]; // [longitude, latitude]
|
||||
};
|
||||
|
||||
isPrimary: boolean;
|
||||
isValidated?: boolean; // backend-only flag used by updateAddressSchema
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -39,12 +70,17 @@ export type UpdateClientAddressPayload = Partial<CreateClientAddressPayload>;
|
||||
|
||||
// ─── Form & Validation ────────────────────────────────────────────────────────
|
||||
|
||||
// FIX: taxId and notes are optional in the actual client form (yup .optional()),
|
||||
// but this interface marked them as required strings. That mismatch is what
|
||||
// caused the "Property 'taxId' is missing" type error on onSubmit.
|
||||
// NOTE: if your form schema truly requires these fields, do the opposite —
|
||||
// remove the "?" here AND make sure the yup schema uses .required() too.
|
||||
export interface ClientFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
taxId: string;
|
||||
notes: string;
|
||||
taxId?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ClientFormErrors {
|
||||
|
||||
87
src/validations/client.validator.ts
Normal file
87
src/validations/client.validator.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
|
||||
import * as yup from "yup";
|
||||
|
||||
// ─── Saudi phone regex ───────────────────────────
|
||||
// accept: +966 5xxxxxxxx | 966 5xxxxxxxx | 05xxxxxxxx | 5xxxxxxxx
|
||||
const SAUDI_PHONE_REGEX = /^(\+966|966|0)?5[0-9]{8}$/;
|
||||
const SAUDI_PHONE_MSG = "صيغة الهاتف غير صحيحة (مثال: 05xxxxxxxx)";
|
||||
|
||||
// ─── clientType values enums ────────────────────────────
|
||||
export const CLIENT_TYPES = ["Individual", "Corporate"] as const;
|
||||
export type ClientType = typeof CLIENT_TYPES[number];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Inferred TypeScript types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// data type create fprm
|
||||
export type CreateClientFormValues = yup.InferType<typeof createClientSchema>;
|
||||
|
||||
// data type update form
|
||||
export type UpdateClientFormValues = yup.InferType<typeof updateClientSchema>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// createClientSchema
|
||||
|
||||
export const createClientSchema = yup.object({
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required("اسم العميل مطلوب")
|
||||
.min(2, "الاسم يجب أن يكون حرفين على الأقل"),
|
||||
|
||||
phone: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required("رقم الهاتف مطلوب")
|
||||
.matches(SAUDI_PHONE_REGEX, SAUDI_PHONE_MSG),
|
||||
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.email("صيغة البريد الإلكتروني غير صحيحة")
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? undefined : val)),
|
||||
|
||||
clientType: yup
|
||||
.mixed<ClientType>()
|
||||
.oneOf([...CLIENT_TYPES] as ClientType[], "نوع العميل غير صالح")
|
||||
.optional(),
|
||||
}).required();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// updateClientSchema
|
||||
|
||||
export const updateClientSchema = yup.object({
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "الاسم يجب أن يكون حرفين على الأقل")
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? undefined : val)),
|
||||
|
||||
phone: yup
|
||||
.string()
|
||||
.trim()
|
||||
.matches(SAUDI_PHONE_REGEX, {
|
||||
message: SAUDI_PHONE_MSG,
|
||||
excludeEmptyString: true,
|
||||
})
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? undefined : val)),
|
||||
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.email("صيغة البريد الإلكتروني غير صحيحة")
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? undefined : val)),
|
||||
|
||||
clientType: yup
|
||||
.mixed<ClientType>()
|
||||
.oneOf([...CLIENT_TYPES] as ClientType[], "نوع العميل غير صالح")
|
||||
.optional(),
|
||||
isActive: yup
|
||||
.boolean()
|
||||
.optional(),
|
||||
}).required();
|
||||
204
src/validations/client_address.validator.ts
Normal file
204
src/validations/client_address.validator.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import * as yup from "yup";
|
||||
|
||||
// ─── Saudi phone regex ───────────────────────────
|
||||
const SAUDI_PHONE_REGEX = /^(\+966|966|0)?5[0-9]{8}$/;
|
||||
const SAUDI_PHONE_MSG = "صيغة الهاتف غير صحيحة (مثال: 05xxxxxxxx)";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Inferred TypeScript types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export type CreateAddressFormValues = yup.InferType<typeof createAddressSchema>;
|
||||
export type UpdateAddressFormValues = yup.InferType<typeof updateAddressSchema>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sub-schemas (shared between create & update)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* contactPersonSchema
|
||||
|
||||
*/
|
||||
const contactPersonSchema = yup.object({
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
|
||||
phone: yup
|
||||
.string()
|
||||
.trim()
|
||||
.matches(SAUDI_PHONE_REGEX, {
|
||||
message: SAUDI_PHONE_MSG,
|
||||
excludeEmptyString: true,
|
||||
})
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
}).optional();
|
||||
|
||||
/**
|
||||
* detailsCreateSchema
|
||||
|
||||
*/
|
||||
const detailsCreateSchema = yup.object({
|
||||
country: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default("SA"),
|
||||
|
||||
city: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required("المدينة مطلوبة"),
|
||||
|
||||
state: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
|
||||
district: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
|
||||
street: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required("الشارع / العنوان التفصيلي مطلوب"),
|
||||
|
||||
buildingNo: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
|
||||
unitNo: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
|
||||
additionalNo: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
|
||||
zipCode: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
|
||||
apartment: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
}).required();
|
||||
|
||||
/**
|
||||
* detailsUpdateSchema
|
||||
*/
|
||||
const detailsUpdateSchema = yup.object({
|
||||
country: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
city: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
state: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
district: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
street: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
buildingNo: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
unitNo: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
additionalNo:yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
zipCode: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
apartment: yup.string().trim().optional().transform((v) => (v === "" ? undefined : v)),
|
||||
}).optional();
|
||||
|
||||
/**
|
||||
* locationCreateSchema
|
||||
*/
|
||||
const locationCreateSchema = yup.object({
|
||||
coordinates: yup
|
||||
.array()
|
||||
.of(yup.number().required())
|
||||
.length(2, "الإحداثيات يجب أن تكون [خط الطول، خط العرض]")
|
||||
.required("إحداثيات الموقع مطلوبة"),
|
||||
}).required();
|
||||
|
||||
/**
|
||||
* locationUpdateSchema
|
||||
*/
|
||||
const locationUpdateSchema = yup.object({
|
||||
coordinates: yup
|
||||
.array()
|
||||
.of(yup.number().required())
|
||||
.length(2, "الإحداثيات يجب أن تكون [خط الطول، خط العرض]")
|
||||
.optional(),
|
||||
}).optional();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// createAddressSchema
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const createAddressSchema = yup.object({
|
||||
label: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default("General"),
|
||||
|
||||
branchName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? null : v)),
|
||||
|
||||
contactPerson: contactPersonSchema,
|
||||
|
||||
details: detailsCreateSchema,
|
||||
|
||||
location: locationCreateSchema,
|
||||
|
||||
|
||||
isPrimary: yup
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false),
|
||||
}).required();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// updateAddressSchema
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const updateAddressSchema = yup.object({
|
||||
label: yup
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
|
||||
branchName: yup
|
||||
.string()
|
||||
.trim()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? null : v)),
|
||||
|
||||
contactPerson: contactPersonSchema,
|
||||
|
||||
details: detailsUpdateSchema,
|
||||
|
||||
location: locationUpdateSchema,
|
||||
|
||||
// isValidated — backend-only field،
|
||||
isValidated: yup
|
||||
.boolean()
|
||||
.optional(),
|
||||
|
||||
// isPrimary — frontend-only
|
||||
isPrimary: yup
|
||||
.boolean()
|
||||
.optional(),
|
||||
}).required();
|
||||
@@ -5,7 +5,20 @@ const SAUDI_PHONE_RE = /^(05\d{8}|009665\d{8}|\+9665\d{8})$/;
|
||||
|
||||
// ── Address sub-schemas (mirrors backend orderAddressSchema) ─────────────────
|
||||
|
||||
// Delivery address — location.coordinates required by the backend (MongoDB GeoJSON)
|
||||
// Delivery address — coordinates SHOULD be required by the backend (MongoDB
|
||||
// GeoJSON), but they must NOT be marked .required() at the yup-schema level
|
||||
// here. yup.object({...}).optional() on the parent does not skip validating
|
||||
// .required() fields nested two levels down when the parent key is missing
|
||||
// from the payload entirely — yup still walks into the nested object schema
|
||||
// and fails on the inner .required(), even though `deliveryAddress` was never
|
||||
// sent in the validated value. (Confirmed by direct testing: a payload with
|
||||
// no `deliveryAddress` key at all still threw "إحداثيات موقع التسليم مطلوبة"
|
||||
// before this fix — which is exactly why handleSubmit appeared to silently
|
||||
// do nothing on every submit once this field was added.)
|
||||
//
|
||||
// Real requiredness (only require coordinates once the user is actually
|
||||
// filling in a delivery address) is enforced in OrderFormModal.tsx instead,
|
||||
// right before building the payload.
|
||||
const deliveryAddressSchema = yup.object({
|
||||
details: yup.object({
|
||||
city: yup.string().trim().optional(),
|
||||
@@ -21,8 +34,8 @@ const deliveryAddressSchema = yup.object({
|
||||
.array()
|
||||
.of(yup.number().required())
|
||||
.length(2, "الإحداثيات يجب أن تكون [خط الطول, خط العرض]")
|
||||
.required("إحداثيات موقع التسليم مطلوبة"),
|
||||
}).required("موقع التسليم مطلوب"),
|
||||
.optional(),
|
||||
}).optional(),
|
||||
}).optional();
|
||||
|
||||
// Pickup address — coordinates optional (user may supply an existing ID instead)
|
||||
|
||||
Reference in New Issue
Block a user