edit location info

This commit is contained in:
m7amedez5511
2026-08-23 15:34:18 +03:00
parent b27da3f6e8
commit 0658e59d9b
9 changed files with 809 additions and 79 deletions

View File

@@ -1,54 +1,144 @@
"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 { Alert, Button, Input, Modal } from "../UI";
import { Alert, Button, Input, Modal, Select } from "../UI";
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";
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 ─────────────────────────────────────────────────────────────────────
interface BranchFormModalProps {
editBranch: Branch | null;
onClose: () => void;
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
onClose: () => void;
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
}
// ── main component ────────────────────────────────────────────────────────────
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
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 {
register,
handleSubmit,
setError,
setValue,
watch,
control,
formState: { errors, isSubmitting },
} = useForm<BranchFormData>({
// Cast the schema itself and pin the generic explicitly on yupResolver —
// 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),
} = useForm<BranchFormValues>({
resolver: yupResolver<BranchFormValues>((isNew ? createBranchSchema : updateBranchSchema) as any),
defaultValues: {
name: editBranch?.name ?? "",
email: editBranch?.email ?? "",
phone: editBranch?.phone ?? "",
country: editBranch?.country ?? "SA",
city: editBranch?.city ?? "",
state: editBranch?.state ?? "",
district: editBranch?.district ?? "",
street: editBranch?.street ?? "",
name: editBranch?.name ?? "",
email: editBranch?.email ?? "",
phone: editBranch?.phone ?? "",
country: editBranch?.country ?? "SA",
regionId: resolvedEdit?.regionId ?? "",
cityId: resolvedEdit?.cityId ?? "",
districtId: resolvedEdit?.districtId ?? "",
city: editBranch?.city ?? "",
state: editBranch?.state ?? "",
district: editBranch?.district ?? "",
street: editBranch?.street ?? "",
buildingNo: editBranch?.buildingNo ?? "",
unitNo: editBranch?.unitNo ?? "",
zipCode: editBranch?.zipCode ?? "",
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "",
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "",
unitNo: editBranch?.unitNo ?? "",
zipCode: editBranch?.zipCode ?? "",
latitude: resolvedEdit?.latitude != null ? String(resolvedEdit.latitude) : editBranch?.latitude != null ? String(editBranch.latitude) : "",
longitude: resolvedEdit?.longitude != null ? String(resolvedEdit.longitude) : editBranch?.longitude != null ? String(editBranch.longitude) : "",
},
});
const submitHandler = async (data: BranchFormData) => {
const ok = await onSubmit(data, isNew);
// Step 3: Watch the two parent selects so the child dropdowns can filter
// 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) {
onClose();
} else {
@@ -84,6 +174,17 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
<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 */}
<Input
label="اسم الفرع *"
@@ -117,15 +218,73 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
/>
</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" }}>
<Input
label="المدينة *"
{...register("city")}
error={errors.city?.message}
placeholder="الرياض"
dir="rtl"
<Controller
name="regionId"
control={control}
render={({ field }) => (
<Select
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
label="الشارع *"
{...register("street")}
@@ -135,24 +294,6 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
/>
</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 */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<Input
@@ -185,25 +326,33 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
error={errors.country?.message}
placeholder="SA"
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" }}>
<Input
label="خط العرض (اختياري)"
label="خط العرض (تلقائي)"
{...register("latitude")}
error={errors.latitude?.message}
placeholder="24.7136"
placeholder=""
dir="ltr"
inputMode="decimal"
readOnly
disabled
/>
<Input
label="خط الطول (اختياري)"
label="خط الطول (تلقائي)"
{...register("longitude")}
error={errors.longitude?.message}
placeholder="46.6753"
placeholder=""
dir="ltr"
inputMode="decimal"
readOnly
disabled
/>
</div>
</form>

View File

@@ -373,7 +373,7 @@ export function OrderFormModal({
</Select>
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
إنشاء عميل جديد
إضافة عميل جديد
</Link>
</div>

View File

@@ -54,14 +54,14 @@ export function OrderTable({
</div>
),
},
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.client?.name ?? "—" },
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.clientId ?? "—" },
{
key: "amount",
header: "الكمية",
width: "1.1fr",
render: (o) => (
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
{fmtAmount(o.totalPrice)}
{o.quantity ?? "—"}
</span>
),
},

54
src/data/location.ts Normal file
View 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;
}

View 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[];
}

View 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 },
],
},
],
},
];

View File

@@ -79,8 +79,11 @@ export function useBranches() {
const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => {
try {
const token = getStoredToken();
const res = await branchService.update(id, data, token);
dispatch({ type: "UPDATE", branch: res?.data });
const branch = await branchService.update(id, data, token);
if (!branch?.id) {
throw new Error("لم يُرجع الخادم بيانات الفرع المحدث.");
}
dispatch({ type: "UPDATE", branch });
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
return true;
} catch (err) {

View 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;
}

View File

@@ -1,10 +1,29 @@
import * as yup from "yup";
import { isValidLocationChain } from "@/src/lib/locationHierarchy";
// ── Phone regex — same as backend ─────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────────
// 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({
name: yup
@@ -26,17 +45,34 @@ export const createBranchSchema = yup.object({
.string()
.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()
.required("المدينة مطلوبة"),
state: yup
districtId: yup
.string()
.optional(),
.required("الحي مطلوب")
.test(
"location-chain-consistency",
"الحي المختار لا يتبع المدينة/المنطقة المختارة",
locationChainTest,
),
district: yup
.string()
.optional(),
// Step 4: Kept as plain strings — these are populated automatically from
// the resolved RegionEntry/CityEntry/DistrictEntry names right before the
// 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
.string()
@@ -52,21 +88,32 @@ export const createBranchSchema = yup.object({
zipCode: yup
.string()
.matches(/^[0-9]{5}$/, { message: "الرمز البريدي يجب أن يتكون من 5 أرقام", excludeEmptyString: true })
.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
.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(),
longitude: yup
.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(),
});
// ── Update schema ─────────────────────────────────────────────────────────────
// 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({
name: yup
@@ -88,17 +135,20 @@ export const updateBranchSchema = yup.object({
.string()
.optional(),
city: yup
regionId: yup.string().optional(),
cityId: yup.string().optional(),
districtId: yup
.string()
.optional(),
.optional()
.test(
"location-chain-consistency",
"الحي المختار لا يتبع المدينة/المنطقة المختارة",
locationChainTest,
),
state: yup
.string()
.optional(),
district: yup
.string()
.optional(),
city: yup.string().optional(),
state: yup.string().optional(),
district: yup.string().optional(),
street: yup
.string()
@@ -114,16 +164,19 @@ export const updateBranchSchema = yup.object({
zipCode: yup
.string()
.matches(/^[0-9]{5}$/, { message: "الرمز البريدي يجب أن يتكون من 5 أرقام", excludeEmptyString: true })
.optional(),
latitude: yup
.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(),
longitude: yup
.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(),
isActive: yup.boolean().optional(),
@@ -137,6 +190,9 @@ export type BranchSchemaErrors = Partial<
| "email"
| "phone"
| "country"
| "regionId"
| "cityId"
| "districtId"
| "city"
| "state"
| "district"