From ff6d2e4104611600f136a335f437719799f01504 Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 10 May 2026 20:06:41 +0100 Subject: [PATCH] fix(front/locations): link locations information with database, we should be able to fetch locations information directly from the database --- front/src/lib/locations.ts | 356 ++++++++++++- front/src/pages/Equipment/EquipmentList.tsx | 8 +- front/src/pages/Locations/LocationsList.tsx | 554 ++++++++++++++------ 3 files changed, 734 insertions(+), 184 deletions(-) diff --git a/front/src/lib/locations.ts b/front/src/lib/locations.ts index 593ac5b..c64dd0f 100644 --- a/front/src/lib/locations.ts +++ b/front/src/lib/locations.ts @@ -1,31 +1,363 @@ -import { gql, useQuery, type UseQueryArgs } from 'urql' +import { gql, useMutation, useQuery, type UseQueryArgs } from 'urql' +import { + Building2, + ChefHat, + Hospital, + Hotel, + type LucideIcon, + MapPin, + UtensilsCrossed, + Warehouse, + Wine, +} from 'lucide-react' +import type { EquipmentRow } from './equipment.ts' -interface Location { +export interface LocationType { id: number name: string + icon: string | null + expected_depth: number } -interface LocationsData { - locations: Location[] +export interface LocationRow { + id: number + company_id: number + parent_id: number | null + location_type_id: number + name: string + path: string + depth: number + timezone: string | null + address: string | null + created_at: string + location_type: Pick +} + +export type OverviewEquipment = EquipmentRow & { location_id: number } + +interface OverviewIssue { + id: string + location_id: number + status: string + severity_id: number | null } -interface LocationsVariables { +export interface LocationsOverviewData { + locations: LocationRow[] + equipment: OverviewEquipment[] + issues: OverviewIssue[] +} + +interface CompanyVars { company_id: number } -const LOCATIONS_QUERY = gql` - query Locations($company_id: Int!) { - locations(where: { company_id: { _eq: $company_id } }, order_by: { name: asc }) { +interface LocationsListData { + locations: LocationRow[] +} + +const LOCATIONS_OVERVIEW_QUERY = gql` + query LocationsOverview($company_id: Int!) { + locations( + where: { company_id: { _eq: $company_id } } + order_by: [{ depth: asc }, { name: asc }] + ) { + id + company_id + parent_id + location_type_id + name + path + depth + timezone + address + created_at + location_type { id name icon } + } + equipment( + where: { + company_id: { _eq: $company_id } + status: { _neq: "decommissioned" } + } + ) { + id + name + serial_number + status + install_date + location_id + location { id name } + equipment_category { id name } + } + issues( + where: { + company_id: { _eq: $company_id } + status: { _nin: ["resolved", "closed"] } + } + ) { + id + location_id + status + severity_id + } + } +` + +const LOCATIONS_LIST_QUERY = gql` + query LocationsSimple($company_id: Int!) { + locations( + where: { company_id: { _eq: $company_id } } + order_by: [{ depth: asc }, { name: asc }] + ) { + id + company_id + parent_id + location_type_id + name + path + depth + timezone + address + created_at + location_type { id name icon } + } + } +` + +const LOCATION_TYPES_QUERY = gql` + query LocationTypes($company_id: Int!) { + location_types( + where: { company_id: { _eq: $company_id } } + order_by: [{ expected_depth: asc }, { name: asc }] + ) { + id + name + icon + expected_depth + } + } +` + +const LOCATION_BY_ID_QUERY = gql` + query LocationById($id: Int!) { + locations_by_pk(id: $id) { + id + company_id + parent_id + location_type_id + name + path + depth + timezone + address + created_at + location_type { id name icon } + } + } +` + +const CREATE_LOCATION_MUTATION = gql` + mutation CreateLocation($object: locations_insert_input!) { + insert_locations_one(object: $object) { + id + name + path + depth + parent_id + location_type_id + } + } +` + +const UPDATE_LOCATION_MUTATION = gql` + mutation UpdateLocation($id: Int!, $set: locations_set_input!) { + update_locations_by_pk(pk_columns: { id: $id }, _set: $set) { id name + address + timezone + location_type_id } } ` +const DELETE_LOCATION_MUTATION = gql` + mutation DeleteLocation($id: Int!) { + delete_locations_by_pk(id: $id) { id } + } +` + +const CREATE_LOCATION_TYPE_MUTATION = gql` + mutation CreateLocationType($object: location_types_insert_input!) { + insert_location_types_one(object: $object) { + id + name + icon + expected_depth + } + } +` + +export const useLocationsOverview = ( + args: Omit, 'query'>, +) => + useQuery({ + query: LOCATIONS_OVERVIEW_QUERY, + ...args, + }) + export const useGetLocations = ( - props: Omit, 'query'>, + args: Omit, 'query'>, ) => - useQuery({ - query: LOCATIONS_QUERY, - ...props, + useQuery({ + query: LOCATIONS_LIST_QUERY, + ...args, }) + +export const useLocationTypes = ( + args: Omit, 'query'>, +) => + useQuery<{ location_types: LocationType[] }, CompanyVars>({ + query: LOCATION_TYPES_QUERY, + ...args, + }) + +export const useLocationById = ( + args: Omit, 'query'>, +) => + useQuery<{ locations_by_pk: LocationRow | null }, { id: number }>({ + query: LOCATION_BY_ID_QUERY, + ...args, + }) + +export interface CreateLocationInput { + company_id: number + name: string + location_type_id: number + parent_id?: number | null + address?: string | null + timezone?: string | null +} + +export const useCreateLocation = () => + useMutation< + { + insert_locations_one: Pick< + LocationRow, + 'id' | 'name' | 'path' | 'depth' | 'parent_id' | 'location_type_id' + > + }, + { object: CreateLocationInput } + >(CREATE_LOCATION_MUTATION) + +export const useUpdateLocation = () => + useMutation< + { + update_locations_by_pk: Pick< + LocationRow, + 'id' | 'name' | 'address' | 'timezone' | 'location_type_id' + > + }, + { + id: number + set: Partial<{ + name: string + address: string | null + timezone: string | null + location_type_id: number + }> + } + >(UPDATE_LOCATION_MUTATION) + +export const useDeleteLocation = () => + useMutation<{ delete_locations_by_pk: { id: number } }, { id: number }>( + DELETE_LOCATION_MUTATION, + ) + +export const useCreateLocationType = () => + useMutation< + { insert_location_types_one: LocationType }, + { + object: { + company_id: number + name: string + icon?: string | null + expected_depth: number + } + } + >(CREATE_LOCATION_TYPE_MUTATION) + +// tree + counts utilities +export interface LocationNode extends LocationRow { + children: LocationNode[] +} + +// build an in-memory tree from a flat list using parent_id (O(n)) +export const buildLocationTree = (locations: LocationRow[]): LocationNode[] => { + const byId = new Map() + for (const loc of locations) byId.set(loc.id, { ...loc, children: [] }) + + const roots: LocationNode[] = [] + for (const node of byId.values()) { + if (node.parent_id == null) { + roots.push(node) + continue + } + const parent = byId.get(node.parent_id) + if (parent) parent.children.push(node) + // orphan + else roots.push(node) + } + return roots +} + +// all location IDs in the subtree rooted at `rootPath` (inclusive) +export const subtreeLocationIds = ( + locations: LocationRow[], + rootPath: string, +): Set => { + const prefix = `${rootPath}/` + const ids = new Set() + for (const l of locations) { + if (l.path === rootPath || l.path.startsWith(prefix)) ids.add(l.id) + } + return ids +} + +export interface LocationCounts { + equipment: number + activeIssues: number +} + +// count equipment + open issues in a location's subtree O(n) +export const computeLocationCounts = ( + rootPath: string, + locations: LocationRow[], + equipment: OverviewEquipment[], + issues: OverviewIssue[], +): LocationCounts => { + const subtree = subtreeLocationIds(locations, rootPath) + let eq = 0 + let iss = 0 + for (const e of equipment) if (subtree.has(e.location_id)) eq++ + for (const i of issues) if (subtree.has(i.location_id)) iss++ + return { equipment: eq, activeIssues: iss } +} + +const ICON_MAP: Record = { + restaurant: UtensilsCrossed, + kitchen: ChefHat, + bar: Wine, + storage: Warehouse, + hotel: Hotel, + hospital: Hospital, + office: Building2, + apartment: Building2, + building: Building2, + property: Building2, + floor: Building2, + room: MapPin, +} + +export const getLocationIcon = (key: string | null | undefined): LucideIcon => + (key && ICON_MAP[key.toLowerCase()]) || MapPin diff --git a/front/src/pages/Equipment/EquipmentList.tsx b/front/src/pages/Equipment/EquipmentList.tsx index 6aedf1f..0e9dd26 100644 --- a/front/src/pages/Equipment/EquipmentList.tsx +++ b/front/src/pages/Equipment/EquipmentList.tsx @@ -1,6 +1,6 @@ import { type FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Card, CardContent, CardHeader } from '../../components/Card' +import { Card, CardContent, CardHeader } from '../../components/Card.tsx' import { Table, TableBody, @@ -14,9 +14,9 @@ import { Button } from '../../components/Button.tsx' import { Input, Select } from '../../components/Input.tsx' import { ChevronRight, Plus, QrCode } from 'lucide-react' import { useNavigate } from 'react-router-dom' -import { useAuth } from '../../context/AuthContext' -import { useGetEquipmentCategories, useGetEquipmentList } from '../../lib/equipment' -import { useGetLocations } from '../../lib/locations' +import { useAuth } from '../../context/AuthContext.tsx' +import { useGetEquipmentCategories, useGetEquipmentList } from '../../lib/equipment.ts' +import { useGetLocations } from '../../lib/locations.ts' // debounce on search export function useDebounce(value: T, delay = 300): T { diff --git a/front/src/pages/Locations/LocationsList.tsx b/front/src/pages/Locations/LocationsList.tsx index bc8ac41..c99e7cf 100644 --- a/front/src/pages/Locations/LocationsList.tsx +++ b/front/src/pages/Locations/LocationsList.tsx @@ -1,28 +1,51 @@ -import { type FC, useState } from 'react' +import { type FC, type ReactNode, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { AnimatePresence, motion } from 'framer-motion' +import { useNavigate } from 'react-router-dom' +import { + Activity, + AlertTriangle, + ChevronRight, + Download, + Loader2, + MapPin, + Plus, + X, +} from 'lucide-react' import { Card, CardContent } from '../../components/Card.tsx' -import { EQUIPMENT, ISSUES, LOCATIONS } from '../../data/mockData.ts' -import { Activity, Download, MapPin, Plus, X } from 'lucide-react' import { Button } from '../../components/Button.tsx' import { Badge } from '../../components/Badge.tsx' -import { AnimatePresence, motion } from 'framer-motion' -import type { Location } from '../../types/index.ts' -import { useNavigate } from 'react-router-dom' +import { + buildLocationTree, + computeLocationCounts, + getLocationIcon, + type LocationNode, + type LocationRow, + type LocationsOverviewData, + subtreeLocationIds, + useLocationsOverview, +} from '../../lib/locations.ts' +import { useAuth } from '../../context/AuthContext.tsx' -/** - * Page displaying a list of all site locations. - * Allows users to view high-level stats for each site and drill down into inventory. - */ export const LocationsList: FC = () => { - const navigate = useNavigate() const { t } = useTranslation() - const [selectedLoc, setSelectedLoc] = useState(null) - const [isAssetModal, setIsAssetModal] = useState(false) + const { user, isLoading } = useAuth() + const companyId = user?.companyId + const [selectedId, setSelectedId] = useState(null) + + const [{ data, fetching, error }] = useLocationsOverview({ + variables: { company_id: companyId ?? 0 }, + pause: isLoading || companyId == null, + }) + + const locations = useMemo( + () => (data ? buildLocationTree(data.locations) : []), + [data], + ) return (
- {/* Header with page title and primary actions */} -
+

{t('locations.title')} @@ -37,175 +60,370 @@ export const LocationsList: FC = () => { {t('locations.add_new')}

-
+ - {/* Grid of location cards */} -
- {LOCATIONS.map((loc) => { - const locEquipment = EQUIPMENT.filter((e) => e.locationId === loc.id) - const activeIssues = ISSUES.filter( - (is) => is.locationId === loc.id && is.status !== 'closed', - ) - return ( - { - setSelectedLoc(loc) - setIsAssetModal(true) - }} - > - {/* Location cover image with grayscale effect on hover */} -
- {loc.name} -
- -
-
- -
-

- {loc.name} -

-

- {loc.address} -

-
+ {(fetching || isLoading) && ( + } + text={t('common.loading')} + /> + )} + {error && ( + } + text={error.message} + tone='error' + /> + )} + {data && locations.length === 0 && !fetching && ( + } + text={t('locations.empty', 'No locations yet')} + /> + )} - {/* Summary counts for equipment and active alerts */} -
-
-

- {t('locations.registry_items')} -

-

{locEquipment.length}

-
-
-

- {t('locations.active_alerts')} -

-

0 ? 'text-error' : 'text-emerald-600' - }`} - > - {activeIssues.length} -

-
-
-
-
- ) - })} -
+ {data && locations.length > 0 && ( +
+ {locations.map((root) => ( + setSelectedId(root.id)} + /> + ))} +
+ )} - {/* Modal displaying the specific inventory for a selected location */} - {isAssetModal && selectedLoc && ( -
- setIsAssetModal(false)} - className='fixed inset-0 bg-black/60 backdrop-blur-sm' - /> + {selectedId !== null && data && ( + setSelectedId(null)} + /> + )} + +
+ ) +} - -
-
-

- {selectedLoc.name} -

-

- {selectedLoc.address} -

-
+const LocationCard: FC<{ + location: LocationNode + data: LocationsOverviewData + onClick: () => void +}> = ({ location, data, onClick }) => { + const { t } = useTranslation() + const counts = useMemo( + () => + computeLocationCounts( + location.path, + data.locations, + data.equipment, + data.issues, + ), + [location.path, data], + ) + const iconKey = location.location_type.icon || location.location_type.name + const Icon = getLocationIcon(iconKey) + + return ( + +
+
+
+ +
+
+ +
+
+ +
+

+ {location.name} +

+ {location.address && ( +

+ {location.address} +

+ )} +
+ +
+ + 0 ? 'error' : 'ok'} + /> +
+
+ + ) +} + +const Stat: FC<{ label: string; value: number; tone?: 'ok' | 'error' }> = ({ + label, + value, + tone, +}) => ( +
+

+ {label} +

+

+ {value} +

+
+) + +const LocationDetailModal: FC<{ + rootId: number + data: LocationsOverviewData + onClose: () => void +}> = ({ rootId, data, onClose }) => { + const { t } = useTranslation() + const navigate = useNavigate() + const [locationsIds, setLocationsIds] = useState([rootId]) + + const byId = useMemo(() => { + const m = new Map() + for (const l of data.locations) m.set(l.id, l) + return m + }, [data.locations]) + + const childrenByParent = useMemo(() => { + const m = new Map() + for (const l of data.locations) { + if (l.parent_id == null) continue + const arr = m.get(l.parent_id) ?? [] + arr.push(l) + m.set(l.parent_id, arr) + } + return m + }, [data.locations]) + + const currentId = locationsIds[locationsIds.length - 1] + const current = currentId && byId.get(currentId) + + const equipmentInScope = useMemo(() => { + if (!current) return [] + const subtree = subtreeLocationIds(data.locations, current.path) + return data.equipment.filter((e) => subtree.has(e.location_id)) + }, [current, data.locations, data.equipment]) + + if (!current) return null + + const children = childrenByParent.get(current.id) ?? [] + const breadcrumbs = locationsIds + .map((id) => byId.get(id)) + .filter(Boolean) as LocationRow[] + + return ( +
+ + + + {/* Header + breadcrumbs */} +
+
+
+

+ {current.name} +

+ {current.address && ( +

+ {current.address} +

+ )} +
+ +
+ + {breadcrumbs.length > 1 && ( + + )} +
+ +
+ {/* Children locations */} + {children.length > 0 && ( +
+
+

+ {current.location_type.name === 'Restaurant' || + children[0]?.location_type.name === 'Area' + ? t('locations.areas', 'Areas') + : t('locations.children', 'Sub-locations')} + + ({children.length}) + +

+
+ {children.map((child) => { + const c = computeLocationCounts( + child.path, + data.locations, + data.equipment, + data.issues, + ) + return ( + + ) + })} +
+
+ )} + + {/* Equipment list */} +
+
+

+ {t('locations.site_inventory')} + + ({equipmentInScope.length}) + +

+ +
+ + {equipmentInScope.length === 0 && ( +
+ {t('common.no_results')} +
+ )} -
-
-

- {t('locations.site_inventory')} -

+ {equipmentInScope.map((eq) => { + const at = byId.get(eq.location_id) + return ( +
+
+
+ +
+
+

+ {eq.name} +

+

+ {at && at.id !== current.id && <>{at.name} •} + {eq.equipment_category.name} +

+
+
- - {EQUIPMENT.filter((e) => e.locationId === selectedLoc.id).map( - (eq) => ( -
-
-
- -
-
-

- {eq.name} -

-

- {eq.manufacturer} • {eq.qrCodeId} -

-
-
-
- -
-
- ), - )} - - {EQUIPMENT.filter((e) => e.locationId === selectedLoc.id) - .length === 0 && ( -
- {t('common.no_results')} -
- )} -
- -
- )} - + ) + })} +
+
+
) } + +const StateBlock: FC<{ + icon: ReactNode + text: string + tone?: 'error' +}> = ({ icon, text, tone }) => ( +
+ {icon} +

{text}

+
+)