Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
356 changes: 344 additions & 12 deletions front/src/lib/locations.ts
Original file line number Diff line number Diff line change
@@ -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<LocationType, 'id' | 'name' | 'icon'>
}

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<UseQueryArgs<CompanyVars>, 'query'>,
) =>
useQuery<LocationsOverviewData, CompanyVars>({
query: LOCATIONS_OVERVIEW_QUERY,
...args,
})

export const useGetLocations = (
props: Omit<UseQueryArgs<LocationsVariables>, 'query'>,
args: Omit<UseQueryArgs<CompanyVars>, 'query'>,
) =>
useQuery<LocationsData, LocationsVariables>({
query: LOCATIONS_QUERY,
...props,
useQuery<LocationsListData, CompanyVars>({
query: LOCATIONS_LIST_QUERY,
...args,
})

export const useLocationTypes = (
args: Omit<UseQueryArgs<CompanyVars>, 'query'>,
) =>
useQuery<{ location_types: LocationType[] }, CompanyVars>({
query: LOCATION_TYPES_QUERY,
...args,
})

export const useLocationById = (
args: Omit<UseQueryArgs<{ id: number }>, '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<number, LocationNode>()
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<number> => {
const prefix = `${rootPath}/`
const ids = new Set<number>()
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<string, LucideIcon> = {
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
8 changes: 4 additions & 4 deletions front/src/pages/Equipment/EquipmentList.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<T>(value: T, delay = 300): T {
Expand Down
Loading