edit location info
This commit is contained in:
@@ -1,54 +1,144 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import { Alert, Button, Input, Modal } from "../UI";
|
import { Alert, Button, Input, Modal, Select } from "../UI";
|
||||||
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
|
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
|
||||||
|
import {
|
||||||
|
getRegions,
|
||||||
|
getCitiesByRegion,
|
||||||
|
getDistrictsByCity,
|
||||||
|
resolveLocation,
|
||||||
|
findLocationByNames,
|
||||||
|
} from "@/src/lib/locationHierarchy";
|
||||||
import type { Branch, BranchFormData } from "@/src/types/branch";
|
import type { Branch, BranchFormData } from "@/src/types/branch";
|
||||||
|
|
||||||
const FORM_ID = "branch-form";
|
const FORM_ID = "branch-form";
|
||||||
|
|
||||||
|
// Step 1: Extend the plain form-values shape with the three hierarchy ids.
|
||||||
|
// BranchFormData itself stays untouched (see src/types/branch.ts) — city/
|
||||||
|
// state/district there are still plain strings, since that's the API
|
||||||
|
// contract. This local type is what react-hook-form actually manages.
|
||||||
|
interface BranchFormValues extends BranchFormData {
|
||||||
|
regionId: string;
|
||||||
|
cityId: string;
|
||||||
|
districtId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
interface BranchFormModalProps {
|
interface BranchFormModalProps {
|
||||||
editBranch: Branch | null;
|
editBranch: Branch | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
|
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main component ────────────────────────────────────────────────────────────
|
// ── main component ────────────────────────────────────────────────────────────
|
||||||
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
|
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
|
||||||
const isNew = editBranch === null;
|
const isNew = editBranch === null;
|
||||||
|
|
||||||
|
// Step 2: On edit, try to resolve the branch's stored city/state/district
|
||||||
|
// NAMES back into ids against the current dataset. If nothing matches
|
||||||
|
// (the value is a legacy/unknown location no longer in the hierarchy),
|
||||||
|
// resolved stays null and the form falls back to empty selects — the
|
||||||
|
// person must actively re-pick a valid Region -> City -> District chain
|
||||||
|
// before they can save, rather than silently keeping a stale value.
|
||||||
|
const resolvedEdit = useMemo(
|
||||||
|
() => (editBranch ? findLocationByNames(editBranch.state, editBranch.city, editBranch.district) : null),
|
||||||
|
[editBranch],
|
||||||
|
);
|
||||||
|
const isLegacyLocation = !isNew && !resolvedEdit && !!(editBranch?.city || editBranch?.district);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
control,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<BranchFormData>({
|
} = useForm<BranchFormValues>({
|
||||||
// Cast the schema itself and pin the generic explicitly on yupResolver —
|
resolver: yupResolver<BranchFormValues>((isNew ? createBranchSchema : updateBranchSchema) as any),
|
||||||
// create/update schemas differ structurally in which fields are required
|
|
||||||
// (e.g. city/street are optional on update), so yup can't unify them
|
|
||||||
// into the single flat BranchFormData shape on its own.
|
|
||||||
resolver: yupResolver<BranchFormData>((isNew ? createBranchSchema : updateBranchSchema) as any),
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: editBranch?.name ?? "",
|
name: editBranch?.name ?? "",
|
||||||
email: editBranch?.email ?? "",
|
email: editBranch?.email ?? "",
|
||||||
phone: editBranch?.phone ?? "",
|
phone: editBranch?.phone ?? "",
|
||||||
country: editBranch?.country ?? "SA",
|
country: editBranch?.country ?? "SA",
|
||||||
city: editBranch?.city ?? "",
|
regionId: resolvedEdit?.regionId ?? "",
|
||||||
state: editBranch?.state ?? "",
|
cityId: resolvedEdit?.cityId ?? "",
|
||||||
district: editBranch?.district ?? "",
|
districtId: resolvedEdit?.districtId ?? "",
|
||||||
street: editBranch?.street ?? "",
|
city: editBranch?.city ?? "",
|
||||||
|
state: editBranch?.state ?? "",
|
||||||
|
district: editBranch?.district ?? "",
|
||||||
|
street: editBranch?.street ?? "",
|
||||||
buildingNo: editBranch?.buildingNo ?? "",
|
buildingNo: editBranch?.buildingNo ?? "",
|
||||||
unitNo: editBranch?.unitNo ?? "",
|
unitNo: editBranch?.unitNo ?? "",
|
||||||
zipCode: editBranch?.zipCode ?? "",
|
zipCode: editBranch?.zipCode ?? "",
|
||||||
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "",
|
latitude: resolvedEdit?.latitude != null ? String(resolvedEdit.latitude) : editBranch?.latitude != null ? String(editBranch.latitude) : "",
|
||||||
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "",
|
longitude: resolvedEdit?.longitude != null ? String(resolvedEdit.longitude) : editBranch?.longitude != null ? String(editBranch.longitude) : "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitHandler = async (data: BranchFormData) => {
|
// Step 3: Watch the two parent selects so the child dropdowns can filter
|
||||||
const ok = await onSubmit(data, isNew);
|
// themselves reactively (cascading Region -> City -> District).
|
||||||
|
const watchedRegionId = watch("regionId");
|
||||||
|
const watchedCityId = watch("cityId");
|
||||||
|
const watchedDistrictId = watch("districtId");
|
||||||
|
|
||||||
|
const regions = useMemo(() => getRegions(), []);
|
||||||
|
const cities = useMemo(() => getCitiesByRegion(watchedRegionId), [watchedRegionId]);
|
||||||
|
const districts = useMemo(
|
||||||
|
() => getDistrictsByCity(watchedRegionId, watchedCityId),
|
||||||
|
[watchedRegionId, watchedCityId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: Whenever the region changes, clear any city/district selection
|
||||||
|
// that no longer belongs to it — prevents the exact "Mohandessin under
|
||||||
|
// Cairo" class of bug from ever reaching submit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watchedCityId) return;
|
||||||
|
const stillValid = cities.some((c) => c.id === watchedCityId);
|
||||||
|
if (!stillValid) {
|
||||||
|
setValue("cityId", "");
|
||||||
|
setValue("districtId", "");
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [watchedRegionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watchedDistrictId) return;
|
||||||
|
const stillValid = districts.some((d) => d.id === watchedDistrictId);
|
||||||
|
if (!stillValid) setValue("districtId", "");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [watchedCityId]);
|
||||||
|
|
||||||
|
// Step 5: Whenever the fully-resolved district changes, auto-populate
|
||||||
|
// latitude/longitude from the dataset — no manual coordinate entry.
|
||||||
|
useEffect(() => {
|
||||||
|
const resolved = resolveLocation(watchedRegionId, watchedCityId, watchedDistrictId);
|
||||||
|
if (resolved) {
|
||||||
|
setValue("latitude", String(resolved.latitude));
|
||||||
|
setValue("longitude", String(resolved.longitude));
|
||||||
|
}
|
||||||
|
}, [watchedRegionId, watchedCityId, watchedDistrictId, setValue]);
|
||||||
|
|
||||||
|
const submitHandler = async (data: BranchFormValues) => {
|
||||||
|
// Step 6: Resolve the chosen ids into display names right before
|
||||||
|
// building the payload, so the API keeps receiving plain strings for
|
||||||
|
// city/state/district exactly as it does today — this UI change is
|
||||||
|
// fully transparent to the backend contract.
|
||||||
|
const resolved = resolveLocation(data.regionId, data.cityId, data.districtId);
|
||||||
|
|
||||||
|
const payload: BranchFormData = {
|
||||||
|
...data,
|
||||||
|
state: resolved?.regionName ?? data.state,
|
||||||
|
city: resolved?.cityName ?? data.city,
|
||||||
|
district: resolved?.districtName ?? data.district,
|
||||||
|
latitude: resolved ? String(resolved.latitude) : data.latitude,
|
||||||
|
longitude: resolved ? String(resolved.longitude) : data.longitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ok = await onSubmit(payload, isNew);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
@@ -84,6 +174,17 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step 7: Warn once if the stored location no longer matches any
|
||||||
|
valid Region -> City -> District chain, and require a fresh
|
||||||
|
selection instead of silently keeping the legacy value. */}
|
||||||
|
{isLegacyLocation && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message="الموقع المحفوظ لهذا الفرع لم يعد مطابقًا لأي منطقة/مدينة/حي معروف. الرجاء اختيار الموقع الصحيح من القوائم أدناه."
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* name */}
|
{/* name */}
|
||||||
<Input
|
<Input
|
||||||
label="اسم الفرع *"
|
label="اسم الفرع *"
|
||||||
@@ -117,15 +218,73 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* city + street */}
|
{/* Step 8: Cascading Region -> City -> District selects. Each
|
||||||
|
child <Select> is disabled until its parent has a value, and
|
||||||
|
its options come straight from the resolved parent id — a
|
||||||
|
district literally cannot be picked unless it belongs to the
|
||||||
|
currently-selected city. */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Controller
|
||||||
label="المدينة *"
|
name="regionId"
|
||||||
{...register("city")}
|
control={control}
|
||||||
error={errors.city?.message}
|
render={({ field }) => (
|
||||||
placeholder="الرياض"
|
<Select
|
||||||
dir="rtl"
|
label="المنطقة *"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={(errors as any).regionId?.message}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر المنطقة</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="cityId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label="المدينة *"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={(errors as any).cityId?.message}
|
||||||
|
disabled={!watchedRegionId}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">{watchedRegionId ? "اختر المدينة" : "اختر المنطقة أولاً"}</option>
|
||||||
|
{cities.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
|
<Controller
|
||||||
|
name="districtId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label="الحي *"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
error={(errors as any).districtId?.message}
|
||||||
|
disabled={!watchedCityId}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">{watchedCityId ? "اختر الحي" : "اختر المدينة أولاً"}</option>
|
||||||
|
{districts.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="الشارع *"
|
label="الشارع *"
|
||||||
{...register("street")}
|
{...register("street")}
|
||||||
@@ -135,24 +294,6 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* state + district */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
|
||||||
<Input
|
|
||||||
label="المنطقة"
|
|
||||||
{...register("state")}
|
|
||||||
error={errors.state?.message}
|
|
||||||
placeholder="منطقة الرياض"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="الحي"
|
|
||||||
{...register("district")}
|
|
||||||
error={errors.district?.message}
|
|
||||||
placeholder="حي العليا"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* buildingNo + unitNo + zipCode */}
|
{/* buildingNo + unitNo + zipCode */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Input
|
||||||
@@ -185,25 +326,33 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
|
|||||||
error={errors.country?.message}
|
error={errors.country?.message}
|
||||||
placeholder="SA"
|
placeholder="SA"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* latitude + longitude */}
|
{/* Step 9: latitude/longitude are now READ-ONLY — they are derived
|
||||||
|
automatically from the selected district (Step 5 above), so
|
||||||
|
there is no manual entry and therefore no coordinate-vs-district
|
||||||
|
mismatch possible from this form. */}
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||||
<Input
|
<Input
|
||||||
label="خط العرض (اختياري)"
|
label="خط العرض (تلقائي)"
|
||||||
{...register("latitude")}
|
{...register("latitude")}
|
||||||
error={errors.latitude?.message}
|
error={errors.latitude?.message}
|
||||||
placeholder="24.7136"
|
placeholder="—"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="خط الطول (اختياري)"
|
label="خط الطول (تلقائي)"
|
||||||
{...register("longitude")}
|
{...register("longitude")}
|
||||||
error={errors.longitude?.message}
|
error={errors.longitude?.message}
|
||||||
placeholder="46.6753"
|
placeholder="—"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ export function OrderFormModal({
|
|||||||
</Select>
|
</Select>
|
||||||
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
|
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
|
||||||
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
|
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
|
||||||
إنشاء عميل جديد
|
إضافة عميل جديد
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ export function OrderTable({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.client?.name ?? "—" },
|
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.clientId ?? "—" },
|
||||||
{
|
{
|
||||||
key: "amount",
|
key: "amount",
|
||||||
header: "الكمية",
|
header: "الكمية",
|
||||||
width: "1.1fr",
|
width: "1.1fr",
|
||||||
render: (o) => (
|
render: (o) => (
|
||||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
|
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
|
||||||
{fmtAmount(o.totalPrice)}
|
{o.quantity ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
54
src/data/location.ts
Normal file
54
src/data/location.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// src/types/location.ts
|
||||||
|
//
|
||||||
|
// Step 1: Define the Parent/Child hierarchy for Saudi administrative
|
||||||
|
// locations: Region (المنطقة) -> City (المدينة) -> District (الحي).
|
||||||
|
// Step 2: Each District carries its own fixed latitude/longitude, so once
|
||||||
|
// a district is selected in the form, coordinates are derived automatically
|
||||||
|
// instead of being typed manually by the user.
|
||||||
|
// Step 3: Keep this file dependency-free (no imports) so it can be safely
|
||||||
|
// imported from both client components and validation schemas.
|
||||||
|
|
||||||
|
export interface DistrictEntry {
|
||||||
|
// Step 1: Stable id used as the <option value> and for lookups.
|
||||||
|
id: string;
|
||||||
|
// Step 2: Arabic display name (primary language of the UI).
|
||||||
|
name: string;
|
||||||
|
// Step 3: Optional English name, useful for search/debugging/logs.
|
||||||
|
nameEn?: string;
|
||||||
|
// Step 4: Approximate center point of the district — used to
|
||||||
|
// auto-populate latitude/longitude once the district is chosen.
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
// Step 5: Optional list of known streets for this district. Left empty
|
||||||
|
// means the UI falls back to a free-text street input.
|
||||||
|
streets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameEn?: string;
|
||||||
|
// Step 6: A city always belongs to exactly one region (enforced by the
|
||||||
|
// dataset's nesting, not by a back-reference field, to avoid drift).
|
||||||
|
districts: DistrictEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameEn?: string;
|
||||||
|
cities: CityEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Flat, resolved shape returned by lookup helpers — convenient for
|
||||||
|
// building the branch payload and for pre-filling the form on edit.
|
||||||
|
export interface ResolvedLocation {
|
||||||
|
regionId: string;
|
||||||
|
regionName: string;
|
||||||
|
cityId: string;
|
||||||
|
cityName: string;
|
||||||
|
districtId: string;
|
||||||
|
districtName: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
18
src/data/locationHierarchy.ts
Normal file
18
src/data/locationHierarchy.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
interface DistrictEntry {
|
||||||
|
name: string;
|
||||||
|
streets?: string[];
|
||||||
|
boundingBox?: { minLat: number; maxLat: number; minLng: number; maxLng: number };
|
||||||
|
}
|
||||||
|
interface CityEntry {
|
||||||
|
name: string;
|
||||||
|
boundingBox?: { minLat: number; maxLat: number; minLng: number; maxLng: number };
|
||||||
|
districts: DistrictEntry[];
|
||||||
|
}
|
||||||
|
interface StateEntry {
|
||||||
|
name: string;
|
||||||
|
cities: CityEntry[];
|
||||||
|
}
|
||||||
|
interface CountryLocationData {
|
||||||
|
countryCode: string;
|
||||||
|
states: StateEntry[];
|
||||||
|
}
|
||||||
323
src/data/saudiLocationHierarchy.ts
Normal file
323
src/data/saudiLocationHierarchy.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
// src/data/saudiLocationHierarchy.ts
|
||||||
|
//
|
||||||
|
// Step 1: Static reference dataset for Saudi Arabia's 13 administrative
|
||||||
|
// regions (المناطق الإدارية), each with its main city/cities, and a
|
||||||
|
// representative sample of districts (أحياء) per city.
|
||||||
|
// Step 2: This is NOT an exhaustive list of every district in the Kingdom —
|
||||||
|
// it covers the major/most common districts per major city so the
|
||||||
|
// Region -> City -> District chain is enforceable end-to-end today.
|
||||||
|
// Step 3: To extend: add a new CityEntry under the right RegionEntry, or a
|
||||||
|
// new DistrictEntry under the right CityEntry. IDs must stay unique within
|
||||||
|
// their parent list. No other file needs to change — types, validation,
|
||||||
|
// and the form all read from this single source of truth.
|
||||||
|
// Step 4: Coordinates are approximate district-center points (WGS84,
|
||||||
|
// decimal degrees) — accurate enough to auto-fill latitude/longitude on
|
||||||
|
// district selection; not survey-grade.
|
||||||
|
|
||||||
|
import { RegionEntry } from "./location";
|
||||||
|
|
||||||
|
|
||||||
|
export const SAUDI_LOCATION_HIERARCHY: RegionEntry[] = [
|
||||||
|
// Step 5: Region 1 — الرياض (Riyadh)
|
||||||
|
{
|
||||||
|
id: "riyadh-region",
|
||||||
|
name: "منطقة الرياض",
|
||||||
|
nameEn: "Riyadh Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "riyadh-city",
|
||||||
|
name: "الرياض",
|
||||||
|
nameEn: "Riyadh",
|
||||||
|
districts: [
|
||||||
|
{ id: "riyadh-olaya", name: "العليا", nameEn: "Al Olaya", latitude: 24.6944, longitude: 46.6858 },
|
||||||
|
{ id: "riyadh-malaz", name: "الملز", nameEn: "Al Malaz", latitude: 24.6553, longitude: 46.7373 },
|
||||||
|
{ id: "riyadh-nakheel", name: "النخيل", nameEn: "Al Nakheel", latitude: 24.7716, longitude: 46.6285 },
|
||||||
|
{ id: "riyadh-sulaimaniyah", name: "السليمانية", nameEn: "Al Sulaimaniyah", latitude: 24.6917, longitude: 46.7219 },
|
||||||
|
{ id: "riyadh-malqa", name: "الملقا", nameEn: "Al Malqa", latitude: 24.7962, longitude: 46.6289 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dawadmi-city",
|
||||||
|
name: "الدوادمي",
|
||||||
|
nameEn: "Al Dawadmi",
|
||||||
|
districts: [
|
||||||
|
{ id: "dawadmi-center", name: "وسط الدوادمي", nameEn: "Central Dawadmi", latitude: 24.5075, longitude: 44.3931 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 6: Region 2 — مكة المكرمة (Makkah)
|
||||||
|
{
|
||||||
|
id: "makkah-region",
|
||||||
|
name: "منطقة مكة المكرمة",
|
||||||
|
nameEn: "Makkah Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "jeddah-city",
|
||||||
|
name: "جدة",
|
||||||
|
nameEn: "Jeddah",
|
||||||
|
districts: [
|
||||||
|
{ id: "jeddah-rawdah", name: "الروضة", nameEn: "Al Rawdah", latitude: 21.5646, longitude: 39.1728 },
|
||||||
|
{ id: "jeddah-shati", name: "الشاطئ", nameEn: "Al Shati", latitude: 21.6116, longitude: 39.1075 },
|
||||||
|
{ id: "jeddah-salamah", name: "السلامة", nameEn: "Al Salamah", latitude: 21.5729, longitude: 39.1541 },
|
||||||
|
{ id: "jeddah-hamra", name: "الحمراء", nameEn: "Al Hamra", latitude: 21.5498, longitude: 39.1611 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "makkah-city",
|
||||||
|
name: "مكة المكرمة",
|
||||||
|
nameEn: "Makkah",
|
||||||
|
districts: [
|
||||||
|
{ id: "makkah-aziziyah", name: "العزيزية", nameEn: "Al Aziziyah", latitude: 21.3971, longitude: 39.8449 },
|
||||||
|
{ id: "makkah-shisha", name: "الششة", nameEn: "Al Shisha", latitude: 21.4267, longitude: 39.8134 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "taif-city",
|
||||||
|
name: "الطائف",
|
||||||
|
nameEn: "Taif",
|
||||||
|
districts: [
|
||||||
|
{ id: "taif-shuhada", name: "الشهداء", nameEn: "Al Shuhada", latitude: 21.2854, longitude: 40.4183 },
|
||||||
|
{ id: "taif-salamah", name: "السلامة", nameEn: "Al Salamah", latitude: 21.2703, longitude: 40.3985 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 7: Region 3 — المدينة المنورة (Madinah)
|
||||||
|
{
|
||||||
|
id: "madinah-region",
|
||||||
|
name: "منطقة المدينة المنورة",
|
||||||
|
nameEn: "Madinah Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "madinah-city",
|
||||||
|
name: "المدينة المنورة",
|
||||||
|
nameEn: "Madinah",
|
||||||
|
districts: [
|
||||||
|
{ id: "madinah-aziziyah", name: "العزيزية", nameEn: "Al Aziziyah", latitude: 24.4483, longitude: 39.5877 },
|
||||||
|
{ id: "madinah-quba", name: "قباء", nameEn: "Quba", latitude: 24.4392, longitude: 39.6142 },
|
||||||
|
{ id: "madinah-sultanah", name: "السلطانة", nameEn: "Al Sultanah", latitude: 24.5115, longitude: 39.5876 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "yanbu-city",
|
||||||
|
name: "ينبع",
|
||||||
|
nameEn: "Yanbu",
|
||||||
|
districts: [
|
||||||
|
{ id: "yanbu-sinaiyah", name: "ينبع الصناعية", nameEn: "Yanbu Industrial", latitude: 24.0895, longitude: 38.0618 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 8: Region 4 — القصيم (Qassim)
|
||||||
|
{
|
||||||
|
id: "qassim-region",
|
||||||
|
name: "منطقة القصيم",
|
||||||
|
nameEn: "Qassim Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "buraidah-city",
|
||||||
|
name: "بريدة",
|
||||||
|
nameEn: "Buraidah",
|
||||||
|
districts: [
|
||||||
|
{ id: "buraidah-faisaliyah", name: "الفيصلية", nameEn: "Al Faisaliyah", latitude: 26.3418, longitude: 43.9877 },
|
||||||
|
{ id: "buraidah-nahdah", name: "النهضة", nameEn: "Al Nahdah", latitude: 26.3592, longitude: 43.9721 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unaizah-city",
|
||||||
|
name: "عنيزة",
|
||||||
|
nameEn: "Unaizah",
|
||||||
|
districts: [
|
||||||
|
{ id: "unaizah-center", name: "وسط عنيزة", nameEn: "Central Unaizah", latitude: 26.0844, longitude: 43.9935 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 9: Region 5 — المنطقة الشرقية (Eastern Province)
|
||||||
|
{
|
||||||
|
id: "eastern-region",
|
||||||
|
name: "المنطقة الشرقية",
|
||||||
|
nameEn: "Eastern Province",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "dammam-city",
|
||||||
|
name: "الدمام",
|
||||||
|
nameEn: "Dammam",
|
||||||
|
districts: [
|
||||||
|
{ id: "dammam-faisaliyah", name: "الفيصلية", nameEn: "Al Faisaliyah", latitude: 26.4260, longitude: 50.1050 },
|
||||||
|
{ id: "dammam-shati", name: "الشاطئ", nameEn: "Al Shati", latitude: 26.4457, longitude: 50.0928 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "khobar-city",
|
||||||
|
name: "الخبر",
|
||||||
|
nameEn: "Al Khobar",
|
||||||
|
districts: [
|
||||||
|
{ id: "khobar-thuqbah", name: "الثقبة", nameEn: "Al Thuqbah", latitude: 26.2989, longitude: 50.2088 },
|
||||||
|
{ id: "khobar-aqrabiyah", name: "العقربية", nameEn: "Al Aqrabiyah", latitude: 26.2756, longitude: 50.1928 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ahsa-city",
|
||||||
|
name: "الأحساء",
|
||||||
|
nameEn: "Al Ahsa",
|
||||||
|
districts: [
|
||||||
|
{ id: "ahsa-mubarraz", name: "المبرز", nameEn: "Al Mubarraz", latitude: 25.4058, longitude: 49.5928 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 10: Region 6 — عسير (Asir)
|
||||||
|
{
|
||||||
|
id: "asir-region",
|
||||||
|
name: "منطقة عسير",
|
||||||
|
nameEn: "Asir Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "abha-city",
|
||||||
|
name: "أبها",
|
||||||
|
nameEn: "Abha",
|
||||||
|
districts: [
|
||||||
|
{ id: "abha-sad", name: "السد", nameEn: "Al Sad", latitude: 18.2295, longitude: 42.5053 },
|
||||||
|
{ id: "abha-manhal", name: "المنهل", nameEn: "Al Manhal", latitude: 18.2465, longitude: 42.5117 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "khamis-mushait-city",
|
||||||
|
name: "خميس مشيط",
|
||||||
|
nameEn: "Khamis Mushait",
|
||||||
|
districts: [
|
||||||
|
{ id: "khamis-center", name: "وسط خميس مشيط", nameEn: "Central Khamis Mushait", latitude: 18.3060, longitude: 42.7297 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 11: Region 7 — تبوك (Tabuk)
|
||||||
|
{
|
||||||
|
id: "tabuk-region",
|
||||||
|
name: "منطقة تبوك",
|
||||||
|
nameEn: "Tabuk Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "tabuk-city",
|
||||||
|
name: "تبوك",
|
||||||
|
nameEn: "Tabuk",
|
||||||
|
districts: [
|
||||||
|
{ id: "tabuk-sulaymaniyah", name: "السليمانية", nameEn: "Al Sulaymaniyah", latitude: 28.3998, longitude: 36.5715 },
|
||||||
|
{ id: "tabuk-muruj", name: "المروج", nameEn: "Al Muruj", latitude: 28.3776, longitude: 36.5619 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 12: Region 8 — حائل (Hail)
|
||||||
|
{
|
||||||
|
id: "hail-region",
|
||||||
|
name: "منطقة حائل",
|
||||||
|
nameEn: "Hail Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "hail-city",
|
||||||
|
name: "حائل",
|
||||||
|
nameEn: "Hail",
|
||||||
|
districts: [
|
||||||
|
{ id: "hail-nuqrah", name: "النقرة", nameEn: "Al Nuqrah", latitude: 27.5219, longitude: 41.6907 },
|
||||||
|
{ id: "hail-nafl", name: "النفل", nameEn: "Al Nafl", latitude: 27.5350, longitude: 41.7075 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 13: Region 9 — الحدود الشمالية (Northern Borders)
|
||||||
|
{
|
||||||
|
id: "northern-borders-region",
|
||||||
|
name: "منطقة الحدود الشمالية",
|
||||||
|
nameEn: "Northern Borders Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "arar-city",
|
||||||
|
name: "عرعر",
|
||||||
|
nameEn: "Arar",
|
||||||
|
districts: [
|
||||||
|
{ id: "arar-center", name: "وسط عرعر", nameEn: "Central Arar", latitude: 30.9753, longitude: 41.0381 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 14: Region 10 — جازان (Jazan)
|
||||||
|
{
|
||||||
|
id: "jazan-region",
|
||||||
|
name: "منطقة جازان",
|
||||||
|
nameEn: "Jazan Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "jazan-city",
|
||||||
|
name: "جازان",
|
||||||
|
nameEn: "Jazan",
|
||||||
|
districts: [
|
||||||
|
{ id: "jazan-corniche", name: "الكورنيش", nameEn: "Al Corniche", latitude: 16.8892, longitude: 42.5611 },
|
||||||
|
{ id: "jazan-rawdah", name: "الروضة", nameEn: "Al Rawdah", latitude: 16.9083, longitude: 42.5711 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 15: Region 11 — نجران (Najran)
|
||||||
|
{
|
||||||
|
id: "najran-region",
|
||||||
|
name: "منطقة نجران",
|
||||||
|
nameEn: "Najran Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "najran-city",
|
||||||
|
name: "نجران",
|
||||||
|
nameEn: "Najran",
|
||||||
|
districts: [
|
||||||
|
{ id: "najran-nasim", name: "النسيم", nameEn: "Al Nasim", latitude: 17.5656, longitude: 44.2289 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 16: Region 12 — الباحة (Al Bahah)
|
||||||
|
{
|
||||||
|
id: "bahah-region",
|
||||||
|
name: "منطقة الباحة",
|
||||||
|
nameEn: "Al Bahah Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "bahah-city",
|
||||||
|
name: "الباحة",
|
||||||
|
nameEn: "Al Bahah",
|
||||||
|
districts: [
|
||||||
|
{ id: "bahah-center", name: "وسط الباحة", nameEn: "Central Al Bahah", latitude: 20.0129, longitude: 41.4677 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 17: Region 13 — الجوف (Al Jouf)
|
||||||
|
{
|
||||||
|
id: "jouf-region",
|
||||||
|
name: "منطقة الجوف",
|
||||||
|
nameEn: "Al Jouf Region",
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: "sakaka-city",
|
||||||
|
name: "سكاكا",
|
||||||
|
nameEn: "Sakaka",
|
||||||
|
districts: [
|
||||||
|
{ id: "sakaka-center", name: "وسط سكاكا", nameEn: "Central Sakaka", latitude: 29.9697, longitude: 40.2064 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -79,8 +79,11 @@ export function useBranches() {
|
|||||||
const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => {
|
const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await branchService.update(id, data, token);
|
const branch = await branchService.update(id, data, token);
|
||||||
dispatch({ type: "UPDATE", branch: res?.data });
|
if (!branch?.id) {
|
||||||
|
throw new Error("لم يُرجع الخادم بيانات الفرع المحدث.");
|
||||||
|
}
|
||||||
|
dispatch({ type: "UPDATE", branch });
|
||||||
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
|
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
127
src/lib/locationHierarchy.ts
Normal file
127
src/lib/locationHierarchy.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// src/lib/locationHierarchy.ts
|
||||||
|
//
|
||||||
|
// Step 1: Pure lookup helpers over SAUDI_LOCATION_HIERARCHY. No side
|
||||||
|
// effects, no fetch — the dataset is static and imported directly, so
|
||||||
|
// these functions are safe to call from both client components and the
|
||||||
|
// yup validation schema (see src/validations/branch.validator.ts).
|
||||||
|
|
||||||
|
import { SAUDI_LOCATION_HIERARCHY } from "@/src/data/saudiLocationHierarchy";
|
||||||
|
import { RegionEntry, CityEntry, DistrictEntry, ResolvedLocation } from "../data/location";
|
||||||
|
|
||||||
|
|
||||||
|
// Step 2: Return every region — used to populate the first dropdown.
|
||||||
|
export function getRegions(): RegionEntry[] {
|
||||||
|
return SAUDI_LOCATION_HIERARCHY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Return the cities that belong to a given region id. Returns an
|
||||||
|
// empty array (not undefined) when the region id is unknown, so callers
|
||||||
|
// can render an empty <Select> safely without extra null checks.
|
||||||
|
export function getCitiesByRegion(regionId: string | undefined | null): CityEntry[] {
|
||||||
|
if (!regionId) return [];
|
||||||
|
const region = SAUDI_LOCATION_HIERARCHY.find((r) => r.id === regionId);
|
||||||
|
return region?.cities ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Return the districts that belong to a given city id, scoped to
|
||||||
|
// its parent region id (prevents accidentally resolving a city id that
|
||||||
|
// exists twice under two different regions — not currently possible with
|
||||||
|
// this dataset, but kept as a safety net for future data entry mistakes).
|
||||||
|
export function getDistrictsByCity(
|
||||||
|
regionId: string | undefined | null,
|
||||||
|
cityId: string | undefined | null,
|
||||||
|
): DistrictEntry[] {
|
||||||
|
if (!regionId || !cityId) return [];
|
||||||
|
const city = getCitiesByRegion(regionId).find((c) => c.id === cityId);
|
||||||
|
return city?.districts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Resolve a full Region -> City -> District selection into a flat
|
||||||
|
// object with display names + auto-derived coordinates. Returns null if
|
||||||
|
// any part of the chain doesn't actually match (this is the core
|
||||||
|
// consistency check: a district id must genuinely belong to the given
|
||||||
|
// city id, which must genuinely belong to the given region id).
|
||||||
|
export function resolveLocation(
|
||||||
|
regionId: string | undefined | null,
|
||||||
|
cityId: string | undefined | null,
|
||||||
|
districtId: string | undefined | null,
|
||||||
|
): ResolvedLocation | null {
|
||||||
|
if (!regionId || !cityId || !districtId) return null;
|
||||||
|
|
||||||
|
const region = SAUDI_LOCATION_HIERARCHY.find((r) => r.id === regionId);
|
||||||
|
if (!region) return null;
|
||||||
|
|
||||||
|
const city = region.cities.find((c) => c.id === cityId);
|
||||||
|
if (!city) return null;
|
||||||
|
|
||||||
|
const district = city.districts.find((d) => d.id === districtId);
|
||||||
|
if (!district) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
regionId: region.id,
|
||||||
|
regionName: region.name,
|
||||||
|
cityId: city.id,
|
||||||
|
cityName: city.name,
|
||||||
|
districtId: district.id,
|
||||||
|
districtName: district.name,
|
||||||
|
latitude: district.latitude,
|
||||||
|
longitude: district.longitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Convenience boolean wrapper around resolveLocation — used inside
|
||||||
|
// the yup `.test()` cross-field validator.
|
||||||
|
export function isValidLocationChain(
|
||||||
|
regionId: string | undefined | null,
|
||||||
|
cityId: string | undefined | null,
|
||||||
|
districtId: string | undefined | null,
|
||||||
|
): boolean {
|
||||||
|
return resolveLocation(regionId, cityId, districtId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Look up a district's coordinates directly, used to auto-fill
|
||||||
|
// latitude/longitude the moment a district is selected in the form.
|
||||||
|
export function getDistrictCoordinates(
|
||||||
|
regionId: string | undefined | null,
|
||||||
|
cityId: string | undefined | null,
|
||||||
|
districtId: string | undefined | null,
|
||||||
|
): { latitude: number; longitude: number } | null {
|
||||||
|
const resolved = resolveLocation(regionId, cityId, districtId);
|
||||||
|
if (!resolved) return null;
|
||||||
|
return { latitude: resolved.latitude, longitude: resolved.longitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Handle the "edit mode with a legacy/unknown value" case — given
|
||||||
|
// a district NAME coming back from the API (not an id, since the backend
|
||||||
|
// stores display strings today per Branch/BranchDetail), try to find a
|
||||||
|
// matching region/city/district id triplet in the current dataset. If no
|
||||||
|
// match is found, the caller should treat the stored value as a legacy
|
||||||
|
// value and prompt the user to reselect (see BranchFormModal Step 4).
|
||||||
|
export function findLocationByNames(
|
||||||
|
regionName?: string | null,
|
||||||
|
cityName?: string | null,
|
||||||
|
districtName?: string | null,
|
||||||
|
): ResolvedLocation | null {
|
||||||
|
if (!regionName || !cityName || !districtName) return null;
|
||||||
|
|
||||||
|
for (const region of SAUDI_LOCATION_HIERARCHY) {
|
||||||
|
if (region.name !== regionName) continue;
|
||||||
|
for (const city of region.cities) {
|
||||||
|
if (city.name !== cityName) continue;
|
||||||
|
for (const district of city.districts) {
|
||||||
|
if (district.name !== districtName) continue;
|
||||||
|
return {
|
||||||
|
regionId: region.id,
|
||||||
|
regionName: region.name,
|
||||||
|
cityId: city.id,
|
||||||
|
cityName: city.name,
|
||||||
|
districtId: district.id,
|
||||||
|
districtName: district.name,
|
||||||
|
latitude: district.latitude,
|
||||||
|
longitude: district.longitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
import { isValidLocationChain } from "@/src/lib/locationHierarchy";
|
||||||
|
|
||||||
// ── Phone regex — same as backend ─────────────────────────────────────────────
|
// ── Phone regex — same as backend ─────────────────────────────────────────────
|
||||||
const SAUDI_PHONE_RE = /^(\+966|966|0)?5[0-9]{8}$/;
|
const SAUDI_PHONE_RE = /^(\+966|966|0)?5[0-9]{8}$/;
|
||||||
|
|
||||||
|
// Step 1: Cross-field test shared by create/update schemas — verifies the
|
||||||
|
// selected regionId/cityId/districtId genuinely form a valid parent/child
|
||||||
|
// chain in SAUDI_LOCATION_HIERARCHY (e.g. rejects "Mohandessin"-style
|
||||||
|
// mismatches such as a district that doesn't belong to the chosen city).
|
||||||
|
// Step 2: Only runs when all three ids are present — required-ness of each
|
||||||
|
// individual field is handled by its own `.required()` rule below, so this
|
||||||
|
// test focuses purely on chain CONSISTENCY, not presence.
|
||||||
|
function locationChainTest(this: yup.TestContext): boolean {
|
||||||
|
const { regionId, cityId, districtId } = this.parent as {
|
||||||
|
regionId?: string;
|
||||||
|
cityId?: string;
|
||||||
|
districtId?: string;
|
||||||
|
};
|
||||||
|
if (!regionId || !cityId || !districtId) return true; // presence handled elsewhere
|
||||||
|
return isValidLocationChain(regionId, cityId, districtId);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Create schema ─────────────────────────────────────────────────────────────
|
// ── Create schema ─────────────────────────────────────────────────────────────
|
||||||
// Mirrors branch_validator.js createBranchSchema (Zod) field-for-field.
|
// Mirrors branch_validator.js createBranchSchema (Zod) field-for-field, plus
|
||||||
|
// the new Region/City/District hierarchy fields.
|
||||||
|
|
||||||
export const createBranchSchema = yup.object({
|
export const createBranchSchema = yup.object({
|
||||||
name: yup
|
name: yup
|
||||||
@@ -26,17 +45,34 @@ export const createBranchSchema = yup.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
city: yup
|
// Step 3: Hierarchy ids — required on create. `city`/`state`/`district`
|
||||||
|
// (free-text) stay in the schema below ONLY as derived/display fields
|
||||||
|
// populated from the resolved hierarchy, not typed by the user anymore.
|
||||||
|
regionId: yup
|
||||||
|
.string()
|
||||||
|
.required("المنطقة مطلوبة"),
|
||||||
|
|
||||||
|
cityId: yup
|
||||||
.string()
|
.string()
|
||||||
.required("المدينة مطلوبة"),
|
.required("المدينة مطلوبة"),
|
||||||
|
|
||||||
state: yup
|
districtId: yup
|
||||||
.string()
|
.string()
|
||||||
.optional(),
|
.required("الحي مطلوب")
|
||||||
|
.test(
|
||||||
|
"location-chain-consistency",
|
||||||
|
"الحي المختار لا يتبع المدينة/المنطقة المختارة",
|
||||||
|
locationChainTest,
|
||||||
|
),
|
||||||
|
|
||||||
district: yup
|
// Step 4: Kept as plain strings — these are populated automatically from
|
||||||
.string()
|
// the resolved RegionEntry/CityEntry/DistrictEntry names right before the
|
||||||
.optional(),
|
// payload is built (see BranchFormModal submitHandler), so the API
|
||||||
|
// contract (Branch.city / Branch.state / Branch.district as strings)
|
||||||
|
// never changes even though the UI now drives selection via ids.
|
||||||
|
city: yup.string().optional(),
|
||||||
|
state: yup.string().optional(),
|
||||||
|
district: yup.string().optional(),
|
||||||
|
|
||||||
street: yup
|
street: yup
|
||||||
.string()
|
.string()
|
||||||
@@ -52,21 +88,32 @@ export const createBranchSchema = yup.object({
|
|||||||
|
|
||||||
zipCode: yup
|
zipCode: yup
|
||||||
.string()
|
.string()
|
||||||
|
.matches(/^[0-9]{5}$/, { message: "الرمز البريدي يجب أن يتكون من 5 أرقام", excludeEmptyString: true })
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
|
// Step 5: latitude/longitude are now auto-derived from the selected
|
||||||
|
// district (see getDistrictCoordinates) and rendered read-only in the
|
||||||
|
// form — the range check below stays as a defensive guard in case a
|
||||||
|
// future data-entry mistake ever puts an out-of-range value in the
|
||||||
|
// dataset itself, not because the user can type these manually anymore.
|
||||||
latitude: yup
|
latitude: yup
|
||||||
.string()
|
.string()
|
||||||
.test("is-number", "خط العرض غير صالح", v => !v || !isNaN(Number(v)))
|
.test("is-number", "خط العرض غير صالح", (v) => !v || !isNaN(Number(v)))
|
||||||
|
.test("lat-range", "خط العرض يجب أن يكون بين -90 و 90", (v) => !v || (Number(v) >= -90 && Number(v) <= 90))
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
longitude: yup
|
longitude: yup
|
||||||
.string()
|
.string()
|
||||||
.test("is-number", "خط الطول غير صالح", v => !v || !isNaN(Number(v)))
|
.test("is-number", "خط الطول غير صالح", (v) => !v || !isNaN(Number(v)))
|
||||||
|
.test("lng-range", "خط الطول يجب أن يكون بين -180 و 180", (v) => !v || (Number(v) >= -180 && Number(v) <= 180))
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Update schema ─────────────────────────────────────────────────────────────
|
// ── Update schema ─────────────────────────────────────────────────────────────
|
||||||
// Mirrors updateBranchSchema = createBranchSchema.partial().extend({ isActive })
|
// Mirrors updateBranchSchema = createBranchSchema.partial().extend({ isActive })
|
||||||
|
// Step 6: Hierarchy ids become optional on update (a branch may be edited
|
||||||
|
// without touching its location), but IF districtId is provided, the same
|
||||||
|
// chain-consistency test still applies.
|
||||||
|
|
||||||
export const updateBranchSchema = yup.object({
|
export const updateBranchSchema = yup.object({
|
||||||
name: yup
|
name: yup
|
||||||
@@ -88,17 +135,20 @@ export const updateBranchSchema = yup.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
city: yup
|
regionId: yup.string().optional(),
|
||||||
|
cityId: yup.string().optional(),
|
||||||
|
districtId: yup
|
||||||
.string()
|
.string()
|
||||||
.optional(),
|
.optional()
|
||||||
|
.test(
|
||||||
|
"location-chain-consistency",
|
||||||
|
"الحي المختار لا يتبع المدينة/المنطقة المختارة",
|
||||||
|
locationChainTest,
|
||||||
|
),
|
||||||
|
|
||||||
state: yup
|
city: yup.string().optional(),
|
||||||
.string()
|
state: yup.string().optional(),
|
||||||
.optional(),
|
district: yup.string().optional(),
|
||||||
|
|
||||||
district: yup
|
|
||||||
.string()
|
|
||||||
.optional(),
|
|
||||||
|
|
||||||
street: yup
|
street: yup
|
||||||
.string()
|
.string()
|
||||||
@@ -114,16 +164,19 @@ export const updateBranchSchema = yup.object({
|
|||||||
|
|
||||||
zipCode: yup
|
zipCode: yup
|
||||||
.string()
|
.string()
|
||||||
|
.matches(/^[0-9]{5}$/, { message: "الرمز البريدي يجب أن يتكون من 5 أرقام", excludeEmptyString: true })
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
latitude: yup
|
latitude: yup
|
||||||
.string()
|
.string()
|
||||||
.test("is-number", "خط العرض غير صالح", v => !v || !isNaN(Number(v)))
|
.test("is-number", "خط العرض غير صالح", (v) => !v || !isNaN(Number(v)))
|
||||||
|
.test("lat-range", "خط العرض يجب أن يكون بين -90 و 90", (v) => !v || (Number(v) >= -90 && Number(v) <= 90))
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
longitude: yup
|
longitude: yup
|
||||||
.string()
|
.string()
|
||||||
.test("is-number", "خط الطول غير صالح", v => !v || !isNaN(Number(v)))
|
.test("is-number", "خط الطول غير صالح", (v) => !v || !isNaN(Number(v)))
|
||||||
|
.test("lng-range", "خط الطول يجب أن يكون بين -180 و 180", (v) => !v || (Number(v) >= -180 && Number(v) <= 180))
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
isActive: yup.boolean().optional(),
|
isActive: yup.boolean().optional(),
|
||||||
@@ -137,6 +190,9 @@ export type BranchSchemaErrors = Partial<
|
|||||||
| "email"
|
| "email"
|
||||||
| "phone"
|
| "phone"
|
||||||
| "country"
|
| "country"
|
||||||
|
| "regionId"
|
||||||
|
| "cityId"
|
||||||
|
| "districtId"
|
||||||
| "city"
|
| "city"
|
||||||
| "state"
|
| "state"
|
||||||
| "district"
|
| "district"
|
||||||
|
|||||||
Reference in New Issue
Block a user