feat: add shared UI nav + fetch hook + table/select/label/textarea primitives

Basic infra for a functional (no-polish) UI pass over existing entity APIs.
This commit is contained in:
toanhoang
2026-08-03 06:36:16 -04:00
parent 6b39c01f4e
commit 0576d6e61c
7 changed files with 508 additions and 2 deletions
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Small shared data-fetching hook used by every entity page — both for its
// own list table (GET /api/<entity>) and for populating FK <Select>
// dropdowns from other entities' GET endpoints (e.g. an AssetItem page
// fetching /api/employees to build an "assigned to" picker). Deliberately
// minimal: no caching/dedupe/revalidation library, just fetch + state,
// matching the "no polish, just clickable manual testing" scope of this
// task (see CLAUDE.md's "basic functional UI" batch note).
//
// Every API route in this app returns a JSON array on success (200) or
// `{error: string}` on failure (see e.g. src/app/api/employees/route.ts's
// GET/POST) — this hook follows that same shape convention.
type UseApiListResult<T> = {
data: T[];
loading: boolean;
error: string | null;
refetch: () => void;
};
const GENERIC_ERROR_MESSAGE = "Request failed";
export function useApiList<T>(endpoint: string): UseApiListResult<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchCount, setRefetchCount] = useState(0);
const refetch = useCallback(() => {
setRefetchCount((count) => count + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
setLoading(true);
setError(null);
try {
const response = await fetch(endpoint);
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: GENERIC_ERROR_MESSAGE;
if (!cancelled) {
setData([]);
setError(message);
}
return;
}
if (!cancelled) {
setData(Array.isArray(body) ? (body as T[]) : []);
}
} catch {
if (!cancelled) {
setData([]);
setError(GENERIC_ERROR_MESSAGE);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [endpoint, refetchCount]);
return { data, loading, error, refetch };
}