diff --git a/gerbil-manager-web/src/api/client.ts b/gerbil-manager-web/src/api/client.ts index d3751c5..c9cfbca 100644 --- a/gerbil-manager-web/src/api/client.ts +++ b/gerbil-manager-web/src/api/client.ts @@ -62,5 +62,7 @@ export const api = { export const resources = { gerbils: '/gerbils', litters: '/litters', - breeders: '/breeders', + contacts: '/contacts', + enclosures: '/enclosures', + colorVarieties: '/color-varieties', } as const diff --git a/gerbil-manager-web/src/api/gerbils.ts b/gerbil-manager-web/src/api/gerbils.ts new file mode 100644 index 0000000..184bbc4 --- /dev/null +++ b/gerbil-manager-web/src/api/gerbils.ts @@ -0,0 +1,24 @@ +/** Typed API functions for the Tiere (Rennmäuse) resource. */ +import { api, resources } from './client' +import { toQueryString, type GridifyQuery } from './gridify' +import type { CreateGerbil, Gerbil, Paged, UpdateGerbil } from './types' + +export function listGerbils(query: GridifyQuery): Promise> { + return api.get>(`${resources.gerbils}${toQueryString(query)}`) +} + +export function getGerbil(id: string): Promise { + return api.get(`${resources.gerbils}/${id}`) +} + +export function createGerbil(body: CreateGerbil): Promise { + return api.post(resources.gerbils, body) +} + +export function updateGerbil(id: string, body: UpdateGerbil): Promise { + return api.put(`${resources.gerbils}/${id}`, body) +} + +export function deleteGerbil(id: string): Promise { + return api.delete(`${resources.gerbils}/${id}`) +} diff --git a/gerbil-manager-web/src/api/lookups.ts b/gerbil-manager-web/src/api/lookups.ts new file mode 100644 index 0000000..ebed7df --- /dev/null +++ b/gerbil-manager-web/src/api/lookups.ts @@ -0,0 +1,34 @@ +/** + * Lookup resources used to populate form dropdowns (Farbschlag, Becken, + * Kontakte, Würfe). These endpoints are Gridify-paged like the rest; for + * dropdowns we request a large page and read `.items`. + */ +import { api, resources } from './client' +import { toQueryString } from './gridify' +import type { ColorVariety, Contact, Enclosure, Litter, Paged } from './types' + +const ALL = toQueryString({ page: 1, pageSize: 1000, orderBy: 'name' }) + +export async function listColorVarieties(): Promise { + const paged = await api.get>( + `${resources.colorVarieties}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'sortOrder' })}`, + ) + return paged.items +} + +export async function listEnclosures(): Promise { + const paged = await api.get>(`${resources.enclosures}${ALL}`) + return paged.items +} + +export async function listContacts(): Promise { + const paged = await api.get>(`${resources.contacts}${ALL}`) + return paged.items +} + +export async function listLitters(): Promise { + const paged = await api.get>( + `${resources.litters}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'date desc' })}`, + ) + return paged.items +} diff --git a/gerbil-manager-web/src/hooks/useApi.ts b/gerbil-manager-web/src/hooks/useApi.ts new file mode 100644 index 0000000..01c635b --- /dev/null +++ b/gerbil-manager-web/src/hooks/useApi.ts @@ -0,0 +1,98 @@ +/** + * Minimal async-data hooks (no react-query dependency). + * + * `useApi` runs an async loader and tracks loading/error/data, re-running when + * `deps` change. `reload()` re-fetches on demand (e.g. after a mutation). + * + * Design notes (to satisfy the strict react-hooks rules): + * - `loading` is DERIVED (resolved-key !== current-request-key), never set + * synchronously inside the effect. + * - the loader is read through a ref so the effect can key purely on the + * request signature without re-running on every render. + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ApiError } from '../api/client' +import { de } from '../strings/de' + +export interface AsyncState { + data: T | null + loading: boolean + error: string | null + reload: () => void +} + +function messageFor(err: unknown): string { + if (err instanceof ApiError) return err.message + if (err instanceof Error && err.message) return err.message + return de.api.errors.unknown +} + +interface Resolved { + key: string | null + data: T | null + error: string | null +} + +export function useApi(loader: () => Promise, deps: unknown[]): AsyncState { + const loaderRef = useRef(loader) + // Keep the loader ref current (declared before the request effect so it is + // synced first within a commit). Writing refs in render is disallowed. + useEffect(() => { + loaderRef.current = loader + }) + + const [nonce, setNonce] = useState(0) + const requestKey = `${nonce}:${JSON.stringify(deps)}` + + const [resolved, setResolved] = useState>({ key: null, data: null, error: null }) + + useEffect(() => { + let cancelled = false + loaderRef + .current() + .then((result) => { + if (!cancelled) setResolved({ key: requestKey, data: result, error: null }) + }) + .catch((err) => { + if (!cancelled) setResolved({ key: requestKey, data: null, error: messageFor(err) }) + }) + return () => { + cancelled = true + } + }, [requestKey]) + + const reload = useCallback(() => setNonce((n) => n + 1), []) + const loading = resolved.key !== requestKey + + return { data: resolved.data, loading, error: loading ? null : resolved.error, reload } +} + +/** Wraps a mutation (create/update/delete) with pending + error state. */ +export function useMutation( + mutator: (...args: TArgs) => Promise, +): { + run: (...args: TArgs) => Promise + pending: boolean + error: string | null +} { + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + + const run = useCallback( + async (...args: TArgs) => { + setPending(true) + setError(null) + try { + return await mutator(...args) + } catch (err) { + setError(messageFor(err)) + throw err + } finally { + setPending(false) + } + }, + [mutator], + ) + + return { run, pending, error } +}