CI / test (push) Failing after 8s
listAssetItems() now takes {page, pageSize, status, modelId, categoryId,
areaId, search} and returns {items, total} instead of the whole table --
was a bare unpaginated findMany(), shipping all ~613 rows on every page
load. Filtering moved server-side into the same WHERE clause so 'total'
reflects the filtered count, not the table's overall size -- pagination
controls stay correct regardless of which filters are active. 'search'
matches serialNumber, assetTag, or the current (non-returned) assignee's
name via the assignments relation in one query.
GET /api/assets is now the one entity route whose response is not a bare
array (see use-api-list.ts) -- its {items, total} shape needs 'total' for
page controls, so /assets fetches it directly instead of through
useApiList. Query params: page, pageSize, status, modelId, categoryId,
areaId, search.
/assets page: search is debounced 300ms (was firing a request per
keystroke otherwise), changing any filter resets to page 1, added
Previous/Next controls + "showing X-Y / total" count. Row # is now a
running index across pages, not per-page.
Separately: all 135 AssetModel rows from the 2026-08-24 real-data import
have the manufacturer name baked into 'name' already (the source file's
own Model column did, e.g. name="Bose Soundlink Revolve" manufacturer=
"Bose") -- rendering "manufacturer name" everywhere doubled it
("Bose Bose Soundlink Revolve"). Fixed at the display layer
(formatModelLabel(), skips the manufacturer prefix when name already
starts with it) rather than a data migration -- non-destructive, handles
every affected row uniformly, reversible.
Smoke-tested listAssetItems() directly (tsx): page1/page2 have zero
overlap, status filter count matches (71 in_stock), asset-tag search
finds exactly 1 row, assignee-name search correctly joins through
Assignment (4 rows for one employee), last page (13 of 613/50) has the
expected remainder of 13 rows.
tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
802 lines
30 KiB
TypeScript
802 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
|
import { useApiList } from "@/hooks/use-api-list";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
|
|
// Shape returned by GET /api/assets — snake_case per this repo's
|
|
// API-boundary convention (see src/lib/assets.ts's toRecord()).
|
|
interface AssetItemRecord {
|
|
id: string;
|
|
serial_number: string;
|
|
asset_tag: string | null;
|
|
model_id: string;
|
|
purchase_date: string | null;
|
|
warranty_end_date: string | null;
|
|
status: "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
|
cost: number | null;
|
|
cost_currency: string | null;
|
|
area_id: string | null;
|
|
purchase_order_id: string | null;
|
|
external_po_number: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
// Shape returned by GET /api/areas — reused from the office-move feature
|
|
// (see src/lib/areas.ts). Only id/name needed here.
|
|
interface AreaRecord {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
// Shape returned by GET /api/employees — only id/name needed to resolve
|
|
// the "Assigned To" column/search below.
|
|
interface EmployeeRecord {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
// Shape returned by GET /api/assignments — only the fields needed to find
|
|
// each AssetItem's current (non-returned) assignee.
|
|
interface AssignmentRecord {
|
|
employee_id: string;
|
|
asset_item_id: string | null;
|
|
returned_at: string | null;
|
|
}
|
|
|
|
// Shape returned by GET /api/asset-models — only the fields used to populate
|
|
// the modelId <Select> here (see src/app/asset-models/page.tsx's
|
|
// AssetModelRecord for the full shape). category_id added 2026-08-25, used
|
|
// to narrow the Model filter dropdown once a Category is picked -- with
|
|
// 135 models after the 2026-08-24 real-data import, an unfiltered list is
|
|
// unwieldy.
|
|
interface AssetModelRecord {
|
|
id: string;
|
|
category_id: string;
|
|
manufacturer: string;
|
|
name: string;
|
|
}
|
|
|
|
// Shape returned by GET /api/asset-categories — only id/name, used to
|
|
// populate the Category filter <Select> here.
|
|
interface AssetCategoryRecord {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
// Shape returned by GET /api/purchase-orders — only the id, used to populate
|
|
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
|
|
// human-readable name field (see src/app/purchase-orders/page.tsx's
|
|
// PurchaseOrderRecord for the full shape), so the id itself is shown as the
|
|
// label.
|
|
interface PurchaseOrderRecord {
|
|
id: string;
|
|
}
|
|
|
|
// Radix <Select.Item> can't take an empty-string value, so "no purchase
|
|
// order" is represented by this sentinel in the form state and translated to
|
|
// "omit the key" on submit — same pattern as purchase-orders page's
|
|
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
|
|
const NO_PURCHASE_ORDER_VALUE = "none";
|
|
|
|
// Same sentinel pattern as NO_PURCHASE_ORDER_VALUE above, for the optional
|
|
// Area <Select> in the create form.
|
|
const NO_AREA_VALUE = "none";
|
|
|
|
// Renders an ISO date(-time) string as mm/dd/yyyy, or "-" if absent.
|
|
// UTC-based (not local time) to match how purchaseDate/warrantyEndDate are
|
|
// actually stored -- see src/lib/assets.ts's dateString()/addMonthsClamped()
|
|
// comments on why this app treats these as UTC-midnight values throughout,
|
|
// not local-timezone dates.
|
|
function formatDateMMDDYYYY(isoString: string | null): string {
|
|
if (!isoString) return "-";
|
|
const date = new Date(isoString);
|
|
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
const dd = String(date.getUTCDate()).padStart(2, "0");
|
|
const yyyy = date.getUTCFullYear();
|
|
return `${mm}/${dd}/${yyyy}`;
|
|
}
|
|
|
|
// "Manufacturer Name" for display -- except a chunk of AssetModel rows
|
|
// from the 2026-08-24 real-data import already have the manufacturer
|
|
// baked into `name` (the source file's own "Model" column did this for
|
|
// some rows, e.g. name="Logitech Group" with manufacturer="Logitech"),
|
|
// which without this check renders as "Logitech Logitech Group". Skip the
|
|
// manufacturer prefix when `name` already starts with it, case-insensitive.
|
|
function formatModelLabel(manufacturer: string, name: string): string {
|
|
const normalizedName = name.trim().toLowerCase();
|
|
const normalizedManufacturer = manufacturer.trim().toLowerCase();
|
|
if (normalizedManufacturer && normalizedName.startsWith(normalizedManufacturer)) {
|
|
return name;
|
|
}
|
|
return `${manufacturer} ${name}`;
|
|
}
|
|
|
|
const ASSET_ITEMS_PAGE_SIZE = 50;
|
|
|
|
export default function AssetsPage() {
|
|
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
|
|
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
|
|
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
|
const { data: areas } = useApiList<AreaRecord>("/api/areas");
|
|
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
|
|
const { data: assignments } = useApiList<AssignmentRecord>("/api/assignments");
|
|
|
|
const [serialNumber, setSerialNumber] = useState("");
|
|
const [assetTag, setAssetTag] = useState("");
|
|
const [modelId, setModelId] = useState("");
|
|
const [purchaseDate, setPurchaseDate] = useState("");
|
|
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
|
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
|
|
const [areaId, setAreaId] = useState(NO_AREA_VALUE);
|
|
const [cost, setCost] = useState("");
|
|
const [costCurrency, setCostCurrency] = useState("");
|
|
const [externalPoNumber, setExternalPoNumber] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
|
|
// Filter/search + pagination state (2026-08-25 -- was client-side-only
|
|
// filtering over the full ~600+-row table; moved server-side, see
|
|
// src/lib/assets.ts's listAssetItems() doc comment for why). searchText
|
|
// is debounced into debouncedSearchText so typing doesn't fire a
|
|
// request per keystroke.
|
|
const [searchText, setSearchText] = useState("");
|
|
const [debouncedSearchText, setDebouncedSearchText] = useState("");
|
|
const [filterStatus, setFilterStatus] = useState("all");
|
|
const [filterCategoryId, setFilterCategoryId] = useState("all");
|
|
const [filterModelId, setFilterModelId] = useState("all");
|
|
const [filterAreaId, setFilterAreaId] = useState("all");
|
|
const [page, setPage] = useState(1);
|
|
|
|
const [assetItems, setAssetItems] = useState<AssetItemRecord[]>([]);
|
|
const [assetItemsTotal, setAssetItemsTotal] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
function refetch() {
|
|
setRefreshKey((k) => k + 1);
|
|
}
|
|
|
|
useEffect(() => {
|
|
const timeout = setTimeout(() => setDebouncedSearchText(searchText.trim()), 300);
|
|
return () => clearTimeout(timeout);
|
|
}, [searchText]);
|
|
|
|
// A filter changing invalidates whatever page we were on -- e.g. page 3
|
|
// of an unfiltered list may not exist at all once a filter narrows the
|
|
// result set. Reset to page 1 rather than showing an empty/stale page.
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [debouncedSearchText, filterStatus, filterCategoryId, filterModelId, filterAreaId]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError(null);
|
|
const params = new URLSearchParams();
|
|
params.set("page", String(page));
|
|
params.set("pageSize", String(ASSET_ITEMS_PAGE_SIZE));
|
|
if (filterStatus !== "all") params.set("status", filterStatus);
|
|
if (filterCategoryId !== "all") params.set("categoryId", filterCategoryId);
|
|
if (filterModelId !== "all") params.set("modelId", filterModelId);
|
|
if (filterAreaId !== "all") params.set("areaId", filterAreaId);
|
|
if (debouncedSearchText) params.set("search", debouncedSearchText);
|
|
|
|
try {
|
|
const response = await fetch(`/api/assets?${params.toString()}`);
|
|
let body: unknown;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
body = null;
|
|
}
|
|
if (cancelled) return;
|
|
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
|
|
: "Request failed";
|
|
setError(message);
|
|
setAssetItems([]);
|
|
setAssetItemsTotal(0);
|
|
return;
|
|
}
|
|
const parsed = body as { items?: AssetItemRecord[]; total?: number };
|
|
setAssetItems(Array.isArray(parsed.items) ? parsed.items : []);
|
|
setAssetItemsTotal(typeof parsed.total === "number" ? parsed.total : 0);
|
|
} catch {
|
|
if (!cancelled) {
|
|
setError("Request failed");
|
|
setAssetItems([]);
|
|
setAssetItemsTotal(0);
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
}
|
|
|
|
void load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [page, filterStatus, filterCategoryId, filterModelId, filterAreaId, debouncedSearchText, refreshKey]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(assetItemsTotal / ASSET_ITEMS_PAGE_SIZE));
|
|
|
|
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
|
// in the table (2026-08-25 -- was previously the literal FK value).
|
|
const modelNameById = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const m of assetModels) {
|
|
map.set(m.id, formatModelLabel(m.manufacturer, m.name));
|
|
}
|
|
return map;
|
|
}, [assetModels]);
|
|
|
|
const modelCategoryById = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const m of assetModels) {
|
|
map.set(m.id, m.category_id);
|
|
}
|
|
return map;
|
|
}, [assetModels]);
|
|
|
|
// Model dropdown options narrow to the picked Category -- with 135
|
|
// models after the 2026-08-24 real-data import, showing all of them
|
|
// regardless of category is unwieldy.
|
|
const modelsForCategoryFilter = useMemo(() => {
|
|
if (filterCategoryId === "all") return assetModels;
|
|
return assetModels.filter((m) => m.category_id === filterCategoryId);
|
|
}, [assetModels, filterCategoryId]);
|
|
|
|
function handleFilterCategoryChange(categoryId: string) {
|
|
setFilterCategoryId(categoryId);
|
|
// A previously-picked Model may not belong to the newly-picked
|
|
// Category -- reset rather than silently keep filtering on a model
|
|
// that's no longer shown in the (now-narrowed) dropdown.
|
|
if (
|
|
filterModelId !== "all" &&
|
|
categoryId !== "all" &&
|
|
modelCategoryById.get(filterModelId) !== categoryId
|
|
) {
|
|
setFilterModelId("all");
|
|
}
|
|
}
|
|
|
|
const areaNameById = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const a of areas) {
|
|
map.set(a.id, a.name);
|
|
}
|
|
return map;
|
|
}, [areas]);
|
|
|
|
const employeeNameById = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const e of employees) {
|
|
map.set(e.id, e.name);
|
|
}
|
|
return map;
|
|
}, [employees]);
|
|
|
|
// assetItemId -> employee name, current holder only (returned_at IS
|
|
// NULL) -- an AssetItem can have multiple historical Assignment rows
|
|
// over time (see Assignment model's comment), only the active one
|
|
// matters for "who has this right now".
|
|
const assigneeNameByAssetItemId = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const a of assignments) {
|
|
if (a.returned_at !== null || !a.asset_item_id) continue;
|
|
const name = employeeNameById.get(a.employee_id);
|
|
if (name) map.set(a.asset_item_id, name);
|
|
}
|
|
return map;
|
|
}, [assignments, employeeNameById]);
|
|
|
|
// Filtering + pagination both happen server-side now (see the effect
|
|
// above) -- `assetItems` here is already exactly the current page's
|
|
// rows for the active filters, nothing left to compute client-side.
|
|
|
|
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
|
"idle"
|
|
);
|
|
const [importMessage, setImportMessage] = useState<string | null>(null);
|
|
|
|
async function handleImportChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = ""; // allow re-selecting the same file name after an error
|
|
if (!file) return;
|
|
|
|
setImportStatus("uploading");
|
|
setImportMessage(null);
|
|
try {
|
|
const text = await file.text();
|
|
const res = await fetch("/api/assets/import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "text/csv" },
|
|
body: text,
|
|
});
|
|
const body = await res.json();
|
|
if (!res.ok) {
|
|
setImportStatus("error");
|
|
setImportMessage(body.error ?? "Import thất bại");
|
|
return;
|
|
}
|
|
const errorCount = (body.errors as unknown[] | undefined)?.length ?? 0;
|
|
setImportStatus(errorCount > 0 ? "error" : "done");
|
|
const errorSummary =
|
|
errorCount > 0
|
|
? " Lỗi: " +
|
|
(body.errors as { row: number; serialNumber: string; error: string }[])
|
|
.slice(0, 5)
|
|
.map((e) => `dòng ${e.row} (${e.serialNumber || "?"}): ${e.error}`)
|
|
.join("; ") +
|
|
(errorCount > 5 ? ` (+${errorCount - 5} lỗi khác)` : "")
|
|
: "";
|
|
setImportMessage(`Tạo mới ${body.created}, cập nhật ${body.updated} asset.` + errorSummary);
|
|
refetch();
|
|
} catch {
|
|
setImportStatus("error");
|
|
setImportMessage("Không đọc được file -- kiểm tra lại định dạng CSV.");
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setSubmitting(true);
|
|
setSubmitError(null);
|
|
|
|
const hasAssetTag = assetTag.trim() !== "";
|
|
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
|
|
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
|
|
const hasAreaId = areaId !== NO_AREA_VALUE;
|
|
const hasCost = cost.trim() !== "";
|
|
const hasCostCurrency = costCurrency.trim() !== "";
|
|
const hasExternalPoNumber = externalPoNumber.trim() !== "";
|
|
|
|
if (hasCost && Number.isNaN(Number(cost))) {
|
|
setSubmitError("Cost must be a number");
|
|
setSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("/api/assets", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
serialNumber,
|
|
modelId,
|
|
purchaseDate,
|
|
// Omit optional keys entirely when left blank, rather than
|
|
// sending null — matches the create-form pattern used on other
|
|
// pages (e.g. purchase-orders page's receivedAt/receivedById).
|
|
...(hasAssetTag ? { assetTag } : {}),
|
|
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
|
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
|
...(hasAreaId ? { areaId } : {}),
|
|
...(hasCost ? { cost: Number(cost) } : {}),
|
|
...(hasCostCurrency ? { costCurrency } : {}),
|
|
...(hasExternalPoNumber ? { externalPoNumber } : {}),
|
|
}),
|
|
});
|
|
|
|
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
|
|
: "Request failed";
|
|
setSubmitError(message);
|
|
return;
|
|
}
|
|
|
|
setSerialNumber("");
|
|
setAssetTag("");
|
|
setModelId("");
|
|
setPurchaseDate("");
|
|
setWarrantyEndDate("");
|
|
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
|
setAreaId(NO_AREA_VALUE);
|
|
setCost("");
|
|
setCostCurrency("");
|
|
setExternalPoNumber("");
|
|
refetch();
|
|
} catch {
|
|
setSubmitError("Request failed");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8 p-6">
|
|
<h1 className="text-xl font-semibold">Assets</h1>
|
|
|
|
<section className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="search">Search (serial / asset tag / assigned to)</Label>
|
|
<Input
|
|
id="search"
|
|
value={searchText}
|
|
onChange={(event) => setSearchText(event.target.value)}
|
|
placeholder="e.g. DANLAP001"
|
|
className="w-64"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="filterStatus">Status</Label>
|
|
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
|
<SelectTrigger id="filterStatus" className="w-40">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
<SelectItem value="in_stock">in_stock</SelectItem>
|
|
<SelectItem value="assigned">assigned</SelectItem>
|
|
<SelectItem value="repair">repair</SelectItem>
|
|
<SelectItem value="retired">retired</SelectItem>
|
|
<SelectItem value="lost">lost</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="filterCategory">Category</Label>
|
|
<Select value={filterCategoryId} onValueChange={handleFilterCategoryChange}>
|
|
<SelectTrigger id="filterCategory" className="w-52">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
{assetCategories.map((assetCategory) => (
|
|
<SelectItem key={assetCategory.id} value={assetCategory.id}>
|
|
{assetCategory.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="filterModel">Model</Label>
|
|
<Select value={filterModelId} onValueChange={setFilterModelId}>
|
|
<SelectTrigger id="filterModel" className="w-64">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
{modelsForCategoryFilter.map((assetModel) => (
|
|
<SelectItem key={assetModel.id} value={assetModel.id}>
|
|
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="filterArea">Area</Label>
|
|
<Select value={filterAreaId} onValueChange={setFilterAreaId}>
|
|
<SelectTrigger id="filterArea" className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
{areas.map((area) => (
|
|
<SelectItem key={area.id} value={area.id}>
|
|
{area.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<p className="pb-2 text-sm text-muted-foreground">
|
|
{assetItemsTotal === 0
|
|
? "0 assets"
|
|
: `${(page - 1) * ASSET_ITEMS_PAGE_SIZE + 1}-${Math.min(
|
|
page * ASSET_ITEMS_PAGE_SIZE,
|
|
assetItemsTotal
|
|
)} / ${assetItemsTotal} assets`}
|
|
</p>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
|
) : error ? (
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>#</TableHead>
|
|
<TableHead>Serial Number</TableHead>
|
|
<TableHead>Asset Tag</TableHead>
|
|
<TableHead>Model</TableHead>
|
|
<TableHead>Area</TableHead>
|
|
<TableHead>Assigned To</TableHead>
|
|
<TableHead>Purchase Date</TableHead>
|
|
<TableHead>Warranty End Date</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Cost</TableHead>
|
|
<TableHead>Purchase Order ID</TableHead>
|
|
<TableHead>External PO Number</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{assetItems.map((assetItem, index) => (
|
|
<TableRow key={assetItem.id}>
|
|
<TableCell>{(page - 1) * ASSET_ITEMS_PAGE_SIZE + index + 1}</TableCell>
|
|
<TableCell>{assetItem.serial_number}</TableCell>
|
|
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
|
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
|
|
<TableCell>
|
|
{assetItem.area_id ? (areaNameById.get(assetItem.area_id) ?? "-") : "-"}
|
|
</TableCell>
|
|
<TableCell>{assigneeNameByAssetItemId.get(assetItem.id) ?? "-"}</TableCell>
|
|
<TableCell>{formatDateMMDDYYYY(assetItem.purchase_date)}</TableCell>
|
|
<TableCell>{formatDateMMDDYYYY(assetItem.warranty_end_date)}</TableCell>
|
|
<TableCell>{assetItem.status}</TableCell>
|
|
<TableCell>
|
|
{assetItem.cost !== null
|
|
? `${assetItem.cost}${assetItem.cost_currency ? " " + assetItem.cost_currency : ""}`
|
|
: "-"}
|
|
</TableCell>
|
|
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
|
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
{!loading && !error && assetItemsTotal > 0 ? (
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<p className="text-sm text-muted-foreground">
|
|
Page {page} / {totalPages}
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="flex max-w-2xl flex-col gap-3">
|
|
<h2 className="text-lg font-semibold">Import / Export CSV</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Cột nhận diện theo tên (không phân biệt hoa/thường): <code>serial number</code>,{" "}
|
|
<code>manufacturer</code>, <code>model</code> (phải khớp Asset Model đã có sẵn ở{" "}
|
|
<a href="/asset-models" className="underline">
|
|
/asset-models
|
|
</a>
|
|
), <code>purchase date</code> (không bắt buộc -- cũng nhận <code>order received</code>).
|
|
Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
|
|
từ warranty của model), <code>status</code>/<code>state</code> (nhận{" "}
|
|
<code>In use</code> → assigned, <code>In stock</code> → in_stock, bỏ trống thì mặc định
|
|
in_stock), <code>cost</code>, <code>cost currency</code>,{" "}
|
|
<code>po number</code> (text tự do, khác <code>purchase order id</code> là UUID trong
|
|
hệ thống), <code>area</code> (tên khu vực -- tự tạo mới nếu chưa có, cũng nhận{" "}
|
|
<code>stockroom</code>). Dòng có <code>asset tag</code> hoặc <code>serial number</code>{" "}
|
|
đã tồn tại sẽ <b>cập nhật</b> thay vì báo lỗi trùng -- import lại file mới để đồng bộ dữ
|
|
liệu.
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<a href="/api/assets/export">
|
|
<Button type="button" variant="outline">
|
|
Export CSV
|
|
</Button>
|
|
</a>
|
|
|
|
<input
|
|
type="file"
|
|
accept=".csv,text/csv"
|
|
onChange={handleImportChange}
|
|
disabled={importStatus === "uploading"}
|
|
className="hidden"
|
|
id="assets-csv-input"
|
|
/>
|
|
<Button asChild>
|
|
<label htmlFor="assets-csv-input" className="cursor-pointer">
|
|
{importStatus === "uploading" ? "Đang import..." : "Import CSV"}
|
|
</label>
|
|
</Button>
|
|
</div>
|
|
{importMessage ? (
|
|
<p
|
|
className={
|
|
importStatus === "error" ? "text-sm text-destructive" : "text-sm text-foreground"
|
|
}
|
|
>
|
|
{importMessage}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="flex max-w-sm flex-col gap-4">
|
|
<h2 className="text-lg font-semibold">New Asset</h2>
|
|
|
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="serialNumber">Serial Number</Label>
|
|
<Input
|
|
id="serialNumber"
|
|
value={serialNumber}
|
|
onChange={(event) => setSerialNumber(event.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="assetTag">Asset Tag (optional)</Label>
|
|
<Input
|
|
id="assetTag"
|
|
value={assetTag}
|
|
onChange={(event) => setAssetTag(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="modelId">Model</Label>
|
|
<Select value={modelId} onValueChange={setModelId} required>
|
|
<SelectTrigger id="modelId" className="w-full">
|
|
<SelectValue placeholder="Select a model" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{assetModels.map((assetModel) => (
|
|
<SelectItem key={assetModel.id} value={assetModel.id}>
|
|
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="purchaseDate">Purchase Date</Label>
|
|
<Input
|
|
id="purchaseDate"
|
|
type="date"
|
|
value={purchaseDate}
|
|
onChange={(event) => setPurchaseDate(event.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="warrantyEndDate">Warranty End Date (optional)</Label>
|
|
<Input
|
|
id="warrantyEndDate"
|
|
type="date"
|
|
value={warrantyEndDate}
|
|
onChange={(event) => setWarrantyEndDate(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
|
|
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
|
|
<SelectTrigger id="purchaseOrderId" className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
|
|
{purchaseOrders.map((purchaseOrder) => (
|
|
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
|
|
{purchaseOrder.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="areaId">Area (optional)</Label>
|
|
<Select value={areaId} onValueChange={setAreaId}>
|
|
<SelectTrigger id="areaId" className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_AREA_VALUE}>None</SelectItem>
|
|
{areas.map((area) => (
|
|
<SelectItem key={area.id} value={area.id}>
|
|
{area.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="cost">Cost (optional)</Label>
|
|
<Input
|
|
id="cost"
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={cost}
|
|
onChange={(event) => setCost(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="costCurrency">Cost Currency (optional)</Label>
|
|
<Input
|
|
id="costCurrency"
|
|
value={costCurrency}
|
|
onChange={(event) => setCostCurrency(event.target.value)}
|
|
placeholder="e.g. VND, USD"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="externalPoNumber">External PO Number (optional)</Label>
|
|
<Input
|
|
id="externalPoNumber"
|
|
value={externalPoNumber}
|
|
onChange={(event) => setExternalPoNumber(event.target.value)}
|
|
placeholder="PO number from an external system, if any"
|
|
/>
|
|
</div>
|
|
|
|
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
|
|
|
|
<Button type="submit" disabled={submitting}>
|
|
{submitting ? "Creating..." : "Create Asset"}
|
|
</Button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|