import React, { useEffect, useMemo, useRef, useState } from "react"; import { Edit, MapPin, MoreHorizontal, Trash2 } from "lucide-react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../ui/table"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Label } from "../ui/label"; import { Badge } from "../ui/badge"; import { Switch } from "../ui/switch"; import { toast } from "sonner"; import { skipCountForPage } from "../../lib/paginationQuery"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "../ui/pagination"; import { createLocation, deleteLocation, getLocations, updateLocation } from "../../services/locationService"; import type { LocationCreateInput, LocationDto } from "../../types/location"; function toDisplay(v: string | null | undefined): string { const s = (v ?? "").trim(); return s ? s : "N/A"; } function formatGps(lat: number | null | undefined, lng: number | null | undefined): string { if (lat === null || lat === undefined || lng === null || lng === undefined) return "N/A"; if (!Number.isFinite(lat) || !Number.isFinite(lng)) return "N/A"; return `${lat}, ${lng}`; } /** * 红框区域:Location ID / Name、地址、联系方式、GPS(Partner、Group、Active 不要求) * 仅校验是否填写,不校验邮箱/电话格式。 */ function getLocationRedBoxValidationErrors(form: LocationCreateInput): string[] { const errs: string[] = []; if (!form.locationCode.trim()) errs.push("Location ID"); if (!form.locationName.trim()) errs.push("Location Name"); if (!(form.street ?? "").trim()) errs.push("Street"); if (!(form.city ?? "").trim()) errs.push("City"); if (!(form.stateCode ?? "").trim()) errs.push("State"); if (!(form.country ?? "").trim()) errs.push("Country"); if (!(form.zipCode ?? "").trim()) errs.push("Zip Code"); if (!(form.phone ?? "").trim()) errs.push("Phone Number"); if (!(form.email ?? "").trim()) errs.push("Email"); const lat = form.latitude; const lng = form.longitude; if (lat === null || lat === undefined || !Number.isFinite(lat)) errs.push("Latitude"); if (lng === null || lng === undefined || !Number.isFinite(lng)) errs.push("Longitude"); return errs; } export function LocationsView() { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [editingLocation, setEditingLocation] = useState(null); const [deletingLocation, setDeletingLocation] = useState(null); const [locations, setLocations] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [refreshSeq, setRefreshSeq] = useState(0); const [actionsOpenForId, setActionsOpenForId] = useState(null); const [keyword, setKeyword] = useState(""); const [partner, setPartner] = useState("all"); const [groupName, setGroupName] = useState("all"); const [locationPick, setLocationPick] = useState("all"); const [pageIndex, setPageIndex] = useState(1); const [pageSize, setPageSize] = useState(10); const abortRef = useRef(null); const keywordTimerRef = useRef(null); const [debouncedKeyword, setDebouncedKeyword] = useState(""); useEffect(() => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); return () => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); }; }, [keyword]); // Options derived from current result set (no dedicated endpoints provided in doc). const partnerOptions = useMemo(() => { const s = new Set(); for (const x of locations) { const v = (x.partner ?? "").trim(); if (v) s.add(v); } return ["all", ...Array.from(s).sort((a, b) => a.localeCompare(b))]; }, [locations]); const groupOptions = useMemo(() => { const s = new Set(); for (const x of locations) { const v = (x.groupName ?? "").trim(); if (v) s.add(v); } return ["all", ...Array.from(s).sort((a, b) => a.localeCompare(b))]; }, [locations]); const locationOptions = useMemo(() => { const s = new Set(); for (const x of locations) { const v = (x.locationCode ?? "").trim(); if (v) s.add(v); } return ["all", ...Array.from(s).sort((a, b) => a.localeCompare(b))]; }, [locations]); const totalPages = Math.max(1, Math.ceil(total / pageSize)); useEffect(() => { // When filter changes, reset to first page. setPageIndex(1); // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedKeyword, partner, groupName, locationPick, pageSize]); useEffect(() => { const run = async () => { abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; setLoading(true); try { const skipCount = skipCountForPage(pageIndex); const effectiveKeyword = locationPick !== "all" ? locationPick : debouncedKeyword; const res = await getLocations( { skipCount, maxResultCount: pageSize, keyword: effectiveKeyword || undefined, partner: partner !== "all" ? partner : undefined, groupName: groupName !== "all" ? groupName : undefined, }, ac.signal, ); setLocations(res.items ?? []); setTotal(res.totalCount ?? 0); } catch (e: any) { if (e?.name === "AbortError") return; toast.error("Failed to load locations.", { description: e?.message ? String(e.message) : "Please try again.", }); setLocations([]); setTotal(0); } finally { setLoading(false); } }; run(); return () => abortRef.current?.abort(); }, [debouncedKeyword, partner, groupName, locationPick, pageIndex, pageSize, refreshSeq]); const refreshList = () => setRefreshSeq((x) => x + 1); const openEdit = (loc: LocationDto) => { setActionsOpenForId(null); setEditingLocation(loc); setIsEditDialogOpen(true); }; const openDelete = (loc: LocationDto) => { setActionsOpenForId(null); setDeletingLocation(loc); setIsDeleteDialogOpen(true); }; return (
{/* Toolbar - no white background; spacing matches Labels (main p-8 only) */}
{/* Toolbar: Search + Filters + Actions in one row, no "Search" label */}
setKeyword(e.target.value)} style={{ height: 40, boxSizing: 'border-box' }} className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500" />
Not supported yet Not supported yet Not supported yet
{/* Content Area - same padding as Labels (relies on main p-8) */}
Partner Group Location ID Location Name Street City State Country Zip Code Phone Email GPS Active Actions {loading ? ( Loading... ) : locations.length === 0 ? ( No results. ) : ( locations.map((loc) => ( {toDisplay(loc.partner)} {toDisplay(loc.groupName)} {toDisplay(loc.locationCode ?? loc.id)} {toDisplay(loc.locationName)} {toDisplay(loc.street)} {toDisplay(loc.city)} {toDisplay(loc.stateCode)} {toDisplay(loc.country)} {toDisplay(loc.zipCode)} {toDisplay(loc.phone)} {toDisplay(loc.email)} {formatGps(loc.latitude, loc.longitude)} {loc.state ? "Yes" : "No"} setActionsOpenForId(open ? loc.id : null)} > )) )}
Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- {Math.min(pageIndex * pageSize, total)} of {total}
{ e.preventDefault(); setPageIndex((p) => Math.max(1, p - 1)); }} aria-disabled={pageIndex <= 1} className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""} /> e.preventDefault()} > Page {pageIndex} / {totalPages} { e.preventDefault(); setPageIndex((p) => Math.min(totalPages, p + 1)); }} aria-disabled={pageIndex >= totalPages} className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""} />
{ // 新增后强制刷新一次列表;如果当前已在第一页也能刷新。 setPageIndex(1); refreshList(); }} /> { setIsEditDialogOpen(open); if (!open) setEditingLocation(null); }} onUpdated={() => { // 编辑后强制刷新一次列表 refreshList(); }} /> { setIsDeleteDialogOpen(open); if (!open) setDeletingLocation(null); }} onDeleted={() => { refreshList(); }} />
); } // --- Sub-components --- function CreateLocationDialog({ open, onOpenChange, onCreated, }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void; }) { const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState({ partner: "", groupName: "", locationCode: "", locationName: "", street: "", city: "", stateCode: "", country: "", zipCode: "", phone: "", email: "", latitude: null, longitude: null, state: true, }); const resetForm = () => { setForm({ partner: "", groupName: "", locationCode: "", locationName: "", street: "", city: "", stateCode: "", country: "", zipCode: "", phone: "", email: "", latitude: null, longitude: null, state: true, }); }; useEffect(() => { if (!open) { resetForm(); setSubmitting(false); } }, [open]); const submit = async () => { const errs = getLocationRedBoxValidationErrors(form); if (errs.length) { toast.error("Please fill in required fields.", { description: `Missing: ${errs.join(", ")}.`, }); return; } setSubmitting(true); try { await createLocation({ ...form, locationCode: form.locationCode.trim(), locationName: form.locationName.trim(), partner: form.partner?.trim() ? form.partner.trim() : null, groupName: form.groupName?.trim() ? form.groupName.trim() : null, street: (form.street ?? "").trim(), city: (form.city ?? "").trim(), stateCode: (form.stateCode ?? "").trim(), country: (form.country ?? "").trim(), zipCode: (form.zipCode ?? "").trim(), phone: (form.phone ?? "").trim(), email: (form.email ?? "").trim(), latitude: form.latitude!, longitude: form.longitude!, }); toast.success("Location created.", { description: "The location has been added successfully.", }); onOpenChange(false); onCreated(); } catch (e: any) { toast.error("Failed to create location.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Add New Location Enter the details for the new store location.
setForm((p) => ({ ...p, partner: e.target.value }))} />
setForm((p) => ({ ...p, groupName: e.target.value }))} />
setForm((p) => ({ ...p, locationCode: e.target.value }))} />
setForm((p) => ({ ...p, locationName: e.target.value }))} />
setForm((p) => ({ ...p, street: e.target.value }))} />
setForm((p) => ({ ...p, city: e.target.value }))} />
setForm((p) => ({ ...p, stateCode: e.target.value }))} />
setForm((p) => ({ ...p, country: e.target.value }))} />
setForm((p) => ({ ...p, zipCode: e.target.value }))} />
setForm((p) => ({ ...p, phone: e.target.value }))} />
setForm((p) => ({ ...p, email: e.target.value }))} />
{ const raw = e.target.value.trim(); setForm((p) => ({ ...p, latitude: raw ? Number(raw) : null })); }} /> { const raw = e.target.value.trim(); setForm((p) => ({ ...p, longitude: raw ? Number(raw) : null })); }} />
setForm((p) => ({ ...p, state: v }))} />
); } function fromDtoToForm(loc: LocationDto): LocationCreateInput { return { partner: loc.partner ?? "", groupName: loc.groupName ?? "", locationCode: loc.locationCode ?? "", locationName: loc.locationName ?? "", street: loc.street ?? "", city: loc.city ?? "", stateCode: loc.stateCode ?? "", country: loc.country ?? "", zipCode: loc.zipCode ?? "", phone: loc.phone ?? "", email: loc.email ?? "", latitude: loc.latitude ?? null, longitude: loc.longitude ?? null, state: !!loc.state, }; } function EditLocationDialog({ open, location, onOpenChange, onUpdated, }: { open: boolean; location: LocationDto | null; onOpenChange: (open: boolean) => void; onUpdated: () => void; }) { const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState({ partner: "", groupName: "", locationCode: "", locationName: "", street: "", city: "", stateCode: "", country: "", zipCode: "", phone: "", email: "", latitude: null, longitude: null, state: true, }); useEffect(() => { if (open && location) { setForm(fromDtoToForm(location)); setSubmitting(false); } if (!open) setSubmitting(false); }, [open, location]); const submit = async () => { if (!location?.id) return; const errs = getLocationRedBoxValidationErrors(form); if (errs.length) { toast.error("Please fill in required fields.", { description: `Missing: ${errs.join(", ")}.`, }); return; } setSubmitting(true); try { await updateLocation(location.id, { ...form, locationCode: form.locationCode.trim(), locationName: form.locationName.trim(), partner: form.partner?.trim() ? form.partner.trim() : null, groupName: form.groupName?.trim() ? form.groupName.trim() : null, street: (form.street ?? "").trim(), city: (form.city ?? "").trim(), stateCode: (form.stateCode ?? "").trim(), country: (form.country ?? "").trim(), zipCode: (form.zipCode ?? "").trim(), phone: (form.phone ?? "").trim(), email: (form.email ?? "").trim(), latitude: form.latitude!, longitude: form.longitude!, }); toast.success("Location updated.", { description: "The changes have been saved successfully.", }); onOpenChange(false); onUpdated(); } catch (e: any) { toast.error("Failed to update location.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Edit Location Update the details for this store location.
setForm((p) => ({ ...p, partner: e.target.value }))} />
setForm((p) => ({ ...p, groupName: e.target.value }))} />
setForm((p) => ({ ...p, locationCode: e.target.value }))} />
setForm((p) => ({ ...p, locationName: e.target.value }))} />
setForm((p) => ({ ...p, street: e.target.value }))} />
setForm((p) => ({ ...p, city: e.target.value }))} />
setForm((p) => ({ ...p, stateCode: e.target.value }))} />
setForm((p) => ({ ...p, country: e.target.value }))} />
setForm((p) => ({ ...p, zipCode: e.target.value }))} />
setForm((p) => ({ ...p, phone: e.target.value }))} />
setForm((p) => ({ ...p, email: e.target.value }))} />
{ const raw = e.target.value.trim(); setForm((p) => ({ ...p, latitude: raw ? Number(raw) : null })); }} /> { const raw = e.target.value.trim(); setForm((p) => ({ ...p, longitude: raw ? Number(raw) : null })); }} />
setForm((p) => ({ ...p, state: v }))} />
); } function DeleteLocationDialog({ open, location, onOpenChange, onDeleted, }: { open: boolean; location: LocationDto | null; onOpenChange: (open: boolean) => void; onDeleted: () => void; }) { const [submitting, setSubmitting] = useState(false); const name = useMemo(() => { const code = (location?.locationCode ?? "").trim(); const n = (location?.locationName ?? "").trim(); if (code && n) return `${code} - ${n}`; return code || n || "this location"; }, [location?.locationCode, location?.locationName]); const submit = async () => { if (!location?.id) return; setSubmitting(true); try { await deleteLocation(location.id); toast.success("Location deleted.", { description: "The location has been removed successfully.", }); onOpenChange(false); onDeleted(); } catch (e: any) { toast.error("Failed to delete location.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Delete Location This action cannot be undone.
Are you sure you want to delete {name}?
); }