feat(assets): add asset metadata views and enforce assignment transitions

This commit is contained in:
2026-06-19 17:14:22 +02:00
parent c1763ed007
commit f32d55a7b0
17 changed files with 1573 additions and 70 deletions
@@ -0,0 +1,137 @@
"use server"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import type { AssetDetailCopy, AssetStatusCopy } from "../_components/asset.copy"
function formatAssetStatus(
status: string,
statusCopy: AssetStatusCopy,
fallback: { unknownStatus: string },
) {
return status in statusCopy
? statusCopy[status as keyof AssetStatusCopy]
: fallback.unknownStatus
}
function formatDate(value: Date | null | undefined, missingValue: string) {
return value ? value.toISOString().slice(0, 10) : missingValue
}
function formatPrice(
value: { toString(): string } | null | undefined,
missingValue: string,
) {
return value ? value.toString() : missingValue
}
function formatPersonName(
person:
| {
firstName?: string | null
lastName?: string | null
}
| null
| undefined,
missingValue: string,
) {
if (!person) return missingValue
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ")
return fullName || missingValue
}
export default async function AssetDetailPage({
params,
}: {
params: Promise<{ assetId: string }>
}) {
const { assetId } = await params
const asset = await AssetService.findById(assetId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets.detail as AssetDetailCopy
const statusCopy = dictionary.inventory.assets.status
const missingValue = copy.fallback?.missingValue ?? "N/A"
if (!asset) {
return <div>{copy.notFound}</div>
}
return (
<div className="flex flex-col gap-4">
<PageHeader title={copy.title} data={[asset]} />
<dl className="grid gap-4 rounded-lg border p-4 md:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.item}</dt>
<dd>{asset.item?.name ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.serialNumber}
</dt>
<dd>{asset.serialNumber}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.assetTag}</dt>
<dd>{asset.assetTag ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.manufacturer}
</dt>
<dd>{asset.manufacturer ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.model}</dt>
<dd>{asset.model ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.purchaseDate}
</dt>
<dd>{formatDate(asset.purchaseDate, missingValue)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.purchasePrice}
</dt>
<dd>{formatPrice(asset.purchasePrice, missingValue)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.warrantyEndsAt}
</dt>
<dd>{formatDate(asset.warrantyEndsAt, missingValue)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{copy.labels.deliveryNote}
</dt>
<dd>{asset.deliveryNote ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.notes}</dt>
<dd>{asset.notes ?? missingValue}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.status}</dt>
<dd>{formatAssetStatus(asset.status, statusCopy, { unknownStatus: missingValue })}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{copy.labels.person}</dt>
<dd>{formatPersonName(asset.assignment?.person, missingValue)}</dd>
</div>
</dl>
<div>
<Link href={`/inventory/assets/${asset.id}/edit`} passHref>
<Button variant="outline">{copy.actions.edit}</Button>
</Link>
</div>
</div>
)
}
@@ -3,6 +3,7 @@ import type { AssetSchemaCopy } from "@/schemas/asset.schema"
export type AssetListCopy = Dictionary["inventory"]["assets"]["list"] export type AssetListCopy = Dictionary["inventory"]["assets"]["list"]
export type AssetFormCopy = Dictionary["inventory"]["assets"]["form"] export type AssetFormCopy = Dictionary["inventory"]["assets"]["form"]
export type AssetDetailCopy = Dictionary["inventory"]["assets"]["detail"]
export type AssetStatusCopy = Dictionary["inventory"]["assets"]["status"] export type AssetStatusCopy = Dictionary["inventory"]["assets"]["status"]
export type AssetFallbackCopy = Dictionary["inventory"]["assets"]["fallback"] export type AssetFallbackCopy = Dictionary["inventory"]["assets"]["fallback"]
export type { AssetSchemaCopy } export type { AssetSchemaCopy }
@@ -10,7 +10,7 @@ import {
SubmitButton, SubmitButton,
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { ITEM_STATUS } from "@/lib/constants" import { UPDATE_ASSET_STATUSES } from "@/lib/constants"
import { import {
buildUpdateAssetSchema, buildUpdateAssetSchema,
type UpdateAssetFormType, type UpdateAssetFormType,
@@ -102,6 +102,7 @@ export default function EditAssetForm({
</label> </label>
<select <select
id="itemId" id="itemId"
defaultValue={asset.itemId}
{...register("itemId")} {...register("itemId")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
@@ -124,6 +125,7 @@ export default function EditAssetForm({
type="text" type="text"
id="serialNumber" id="serialNumber"
placeholder={formCopy.serialNumberPlaceholder} placeholder={formCopy.serialNumberPlaceholder}
defaultValue={asset.serialNumber}
{...register("serialNumber")} {...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
/> />
@@ -131,6 +133,101 @@ export default function EditAssetForm({
<p className="text-error">{errors?.serialNumber?.message}</p> <p className="text-error">{errors?.serialNumber?.message}</p>
)} )}
</div> </div>
<div>
<label htmlFor="assetTag" className="mb-2 block text-lg">
{formCopy.assetTagLabel}
</label>
<input
type="text"
id="assetTag"
placeholder={formCopy.assetTagPlaceholder}
defaultValue={asset.assetTag ?? undefined}
{...register("assetTag")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.assetTag && (
<p className="text-error">{errors.assetTag.message}</p>
)}
</div>
<div>
<label htmlFor="manufacturer" className="mb-2 block text-lg">
{formCopy.manufacturerLabel}
</label>
<input
type="text"
id="manufacturer"
placeholder={formCopy.manufacturerPlaceholder}
defaultValue={asset.manufacturer ?? undefined}
{...register("manufacturer")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.manufacturer && (
<p className="text-error">{errors.manufacturer.message}</p>
)}
</div>
<div>
<label htmlFor="model" className="mb-2 block text-lg">
{formCopy.modelLabel}
</label>
<input
type="text"
id="model"
placeholder={formCopy.modelPlaceholder}
defaultValue={asset.model ?? undefined}
{...register("model")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.model && <p className="text-error">{errors.model.message}</p>}
</div>
<div>
<label htmlFor="purchaseDate" className="mb-2 block text-lg">
{formCopy.purchaseDateLabel}
</label>
<input
type="date"
id="purchaseDate"
placeholder={formCopy.purchaseDatePlaceholder}
defaultValue={asset.purchaseDate?.toISOString().slice(0, 10)}
{...register("purchaseDate")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchaseDate && (
<p className="text-error">{errors.purchaseDate.message}</p>
)}
</div>
<div>
<label htmlFor="purchasePrice" className="mb-2 block text-lg">
{formCopy.purchasePriceLabel}
</label>
<input
type="number"
step="0.01"
id="purchasePrice"
placeholder={formCopy.purchasePricePlaceholder}
defaultValue={asset.purchasePrice?.toString()}
{...register("purchasePrice")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchasePrice && (
<p className="text-error">{errors.purchasePrice.message}</p>
)}
</div>
<div>
<label htmlFor="warrantyEndsAt" className="mb-2 block text-lg">
{formCopy.warrantyEndsAtLabel}
</label>
<input
type="date"
id="warrantyEndsAt"
placeholder={formCopy.warrantyEndsAtPlaceholder}
defaultValue={asset.warrantyEndsAt?.toISOString().slice(0, 10)}
{...register("warrantyEndsAt")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.warrantyEndsAt && (
<p className="text-error">{errors.warrantyEndsAt.message}</p>
)}
</div>
<div> <div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg"> <label htmlFor="deliveryNote" className="mb-2 block text-lg">
{formCopy.deliveryNoteLabel} {formCopy.deliveryNoteLabel}
@@ -139,6 +236,7 @@ export default function EditAssetForm({
type="text" type="text"
id="deliveryNote" id="deliveryNote"
placeholder={formCopy.deliveryNotePlaceholder} placeholder={formCopy.deliveryNotePlaceholder}
defaultValue={asset.deliveryNote ?? undefined}
{...register("deliveryNote")} {...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
/> />
@@ -152,11 +250,12 @@ export default function EditAssetForm({
</label> </label>
<select <select
id="status" id="status"
defaultValue={asset.status}
{...register("status")} {...register("status")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">{formCopy.statusPlaceholder}</option> <option value="">{formCopy.statusPlaceholder}</option>
{Object.values(ITEM_STATUS).map((status) => ( {UPDATE_ASSET_STATUSES.map((status) => (
<option key={status} value={status}> <option key={status} value={status}>
{statusCopy[status]} {statusCopy[status]}
</option> </option>
@@ -173,6 +272,7 @@ export default function EditAssetForm({
</label> </label>
<select <select
id="personId" id="personId"
defaultValue={asset.assignment?.personId ?? undefined}
{...register("personId")} {...register("personId")}
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
@@ -10,7 +10,7 @@ import {
SubmitButton, SubmitButton,
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { ITEM_STATUS } from "@/lib/constants" import { CREATE_ASSET_STATUSES } from "@/lib/constants"
import { import {
buildCreateAssetSchema, buildCreateAssetSchema,
type CreateAssetFormType, type CreateAssetFormType,
@@ -119,6 +119,95 @@ export default function NewAssetForm({
<p className="text-error">{errors?.serialNumber?.message}</p> <p className="text-error">{errors?.serialNumber?.message}</p>
)} )}
</div> </div>
<div>
<label htmlFor="assetTag" className="mb-2 block text-lg">
{formCopy.assetTagLabel}
</label>
<input
type="text"
id="assetTag"
placeholder={formCopy.assetTagPlaceholder}
{...register("assetTag")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.assetTag && (
<p className="text-error">{errors.assetTag.message}</p>
)}
</div>
<div>
<label htmlFor="manufacturer" className="mb-2 block text-lg">
{formCopy.manufacturerLabel}
</label>
<input
type="text"
id="manufacturer"
placeholder={formCopy.manufacturerPlaceholder}
{...register("manufacturer")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.manufacturer && (
<p className="text-error">{errors.manufacturer.message}</p>
)}
</div>
<div>
<label htmlFor="model" className="mb-2 block text-lg">
{formCopy.modelLabel}
</label>
<input
type="text"
id="model"
placeholder={formCopy.modelPlaceholder}
{...register("model")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.model && <p className="text-error">{errors.model.message}</p>}
</div>
<div>
<label htmlFor="purchaseDate" className="mb-2 block text-lg">
{formCopy.purchaseDateLabel}
</label>
<input
type="date"
id="purchaseDate"
placeholder={formCopy.purchaseDatePlaceholder}
{...register("purchaseDate")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchaseDate && (
<p className="text-error">{errors.purchaseDate.message}</p>
)}
</div>
<div>
<label htmlFor="purchasePrice" className="mb-2 block text-lg">
{formCopy.purchasePriceLabel}
</label>
<input
type="number"
step="0.01"
id="purchasePrice"
placeholder={formCopy.purchasePricePlaceholder}
{...register("purchasePrice")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchasePrice && (
<p className="text-error">{errors.purchasePrice.message}</p>
)}
</div>
<div>
<label htmlFor="warrantyEndsAt" className="mb-2 block text-lg">
{formCopy.warrantyEndsAtLabel}
</label>
<input
type="date"
id="warrantyEndsAt"
placeholder={formCopy.warrantyEndsAtPlaceholder}
{...register("warrantyEndsAt")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.warrantyEndsAt && (
<p className="text-error">{errors.warrantyEndsAt.message}</p>
)}
</div>
<div> <div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg"> <label htmlFor="deliveryNote" className="mb-2 block text-lg">
{formCopy.deliveryNoteLabel} {formCopy.deliveryNoteLabel}
@@ -144,7 +233,7 @@ export default function NewAssetForm({
className="w-full rounded-lg border px-4 py-2" className="w-full rounded-lg border px-4 py-2"
> >
<option value="">{formCopy.statusPlaceholder}</option> <option value="">{formCopy.statusPlaceholder}</option>
{Object.values(ITEM_STATUS).map((status) => ( {CREATE_ASSET_STATUSES.map((status) => (
<option key={status} value={status}> <option key={status} value={status}>
{statusCopy[status]} {statusCopy[status]}
</option> </option>
+38 -1
View File
@@ -22,6 +22,14 @@ function formatAssetStatus(
: fallbackCopy.unknownStatus : fallbackCopy.unknownStatus
} }
function formatDate(value: Date | null | undefined) {
return value ? value.toISOString().slice(0, 10) : "—"
}
function formatPrice(value: { toString(): string } | null | undefined) {
return value ? value.toString() : "—"
}
export default async function AssetsPage(props: { export default async function AssetsPage(props: {
searchParams?: Promise<{ searchParams?: Promise<{
page?: string page?: string
@@ -70,6 +78,24 @@ export default async function AssetsPage(props: {
<th scope="col" className="p-4"> <th scope="col" className="p-4">
{copy.list.columns.serialNumber} {copy.list.columns.serialNumber}
</th> </th>
<th scope="col" className="p-4">
{copy.list.columns.assetTag}
</th>
<th scope="col" className="p-4">
{copy.list.columns.manufacturer}
</th>
<th scope="col" className="p-4">
{copy.list.columns.model}
</th>
<th scope="col" className="p-4">
{copy.list.columns.purchaseDate}
</th>
<th scope="col" className="p-4">
{copy.list.columns.purchasePrice}
</th>
<th scope="col" className="p-4">
{copy.list.columns.warrantyEndsAt}
</th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
{copy.list.columns.status} {copy.list.columns.status}
</th> </th>
@@ -84,6 +110,12 @@ export default async function AssetsPage(props: {
<td className="p-4">{asset.item?.name}</td> <td className="p-4">{asset.item?.name}</td>
<td className="p-4">{asset.item?.category?.name}</td> <td className="p-4">{asset.item?.category?.name}</td>
<td className="p-4">{asset.serialNumber}</td> <td className="p-4">{asset.serialNumber}</td>
<td className="p-4">{asset.assetTag ?? "—"}</td>
<td className="p-4">{asset.manufacturer ?? "—"}</td>
<td className="p-4">{asset.model ?? "—"}</td>
<td className="p-4">{formatDate(asset.purchaseDate)}</td>
<td className="p-4">{formatPrice(asset.purchasePrice)}</td>
<td className="p-4">{formatDate(asset.warrantyEndsAt)}</td>
<td className="p-4"> <td className="p-4">
{formatAssetStatus( {formatAssetStatus(
asset.status, asset.status,
@@ -92,6 +124,11 @@ export default async function AssetsPage(props: {
)} )}
</td> </td>
<td className="flex items-center gap-2 p-4"> <td className="flex items-center gap-2 p-4">
<Link href={`/inventory/assets/${asset.id}`} passHref>
<Button variant="outline" size="sm">
{copy.list.actions.view}
</Button>
</Link>
<Link href={`/inventory/assets/${asset.id}/edit`} passHref> <Link href={`/inventory/assets/${asset.id}/edit`} passHref>
<Button <Button
variant="outline" variant="outline"
@@ -107,7 +144,7 @@ export default async function AssetsPage(props: {
</tbody> </tbody>
<tfoot className="border-t"> <tfoot className="border-t">
<tr> <tr>
<td colSpan={5} className="p-4 text-center text-sm"> <td colSpan={11} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} /> <PaginationButtons totalPages={totalPages} />
</td> </td>
</tr> </tr>
+18 -1
View File
@@ -22,9 +22,26 @@ export const PERSON_DEPARTMENTS = {
export const ITEM_STATUS = { export const ITEM_STATUS = {
AVAILABLE: "AVAILABLE", AVAILABLE: "AVAILABLE",
ASSIGNED: "ASSIGNED", ASSIGNED: "ASSIGNED",
RESERVED: "RESERVED",
IN_REPAIR: "IN_REPAIR", IN_REPAIR: "IN_REPAIR",
BROKEN: "BROKEN", BROKEN: "BROKEN",
LOST: "LOST",
STOLEN: "STOLEN", STOLEN: "STOLEN",
DISPOSED: "DISPOSED", DISPOSED: "DISPOSED",
RETIRED: "RETIRED",
} as const } as const
export const CREATE_ASSET_STATUSES = [
ITEM_STATUS.AVAILABLE,
ITEM_STATUS.ASSIGNED,
] as const
export const UPDATE_ASSET_STATUSES = [
ITEM_STATUS.AVAILABLE,
ITEM_STATUS.ASSIGNED,
ITEM_STATUS.IN_REPAIR,
ITEM_STATUS.BROKEN,
ITEM_STATUS.LOST,
ITEM_STATUS.STOLEN,
ITEM_STATUS.DISPOSED,
ITEM_STATUS.RETIRED,
] as const
+77 -31
View File
@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
import type { Dictionary } from "@/i18n/dictionaries" import type { Dictionary } from "@/i18n/dictionaries"
import { CREATE_ASSET_STATUSES, UPDATE_ASSET_STATUSES } from "@/lib/constants"
export type AssetSchemaCopy = Dictionary["inventory"]["assets"]["schema"] export type AssetSchemaCopy = Dictionary["inventory"]["assets"]["schema"]
@@ -11,19 +12,35 @@ const defaultAssetSchemaCopy: AssetSchemaCopy = {
statusRequired: "Status is required", statusRequired: "Status is required",
invalidCreateStatus: "Status must be Available or Assigned", invalidCreateStatus: "Status must be Available or Assigned",
invalidUpdateStatus: "Invalid status", invalidUpdateStatus: "Invalid status",
personRequired: "Person is required",
} }
const createAssetStatuses = ["AVAILABLE", "ASSIGNED"] as const function buildOptionalStringSchema() {
const updateAssetStatuses = [ return z
"AVAILABLE", .preprocess(
"ASSIGNED", (value) => (value === "" || value === null ? undefined : value),
"IN_REPAIR", z.string().optional(),
"BROKEN", )
"LOST", .optional()
"STOLEN", }
"DISPOSED",
"RETIRED", function buildOptionalDateSchema() {
] as const return z
.preprocess(
(value) => (value === "" || value === null ? undefined : value),
z.coerce.date().optional(),
)
.optional()
}
function buildOptionalNumberSchema() {
return z
.preprocess(
(value) => (value === "" || value === null ? undefined : value),
z.coerce.number().optional(),
)
.optional()
}
function buildAssetBaseSchema(copy: AssetSchemaCopy) { function buildAssetBaseSchema(copy: AssetSchemaCopy) {
return z.object({ return z.object({
@@ -34,43 +51,72 @@ function buildAssetBaseSchema(copy: AssetSchemaCopy) {
serialNumber: z.string().min(1, { serialNumber: z.string().min(1, {
error: copy.serialNumberRequired, error: copy.serialNumberRequired,
}), }),
assetTag: buildOptionalStringSchema(),
manufacturer: buildOptionalStringSchema(),
model: buildOptionalStringSchema(),
purchaseDate: buildOptionalDateSchema(),
purchasePrice: buildOptionalNumberSchema(),
warrantyEndsAt: buildOptionalDateSchema(),
deliveryNote: z.string().optional(), deliveryNote: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
personId: z.string().optional(), personId: buildOptionalStringSchema(),
})
}
function requirePersonWhenAssigned<T extends z.ZodTypeAny>(
schema: T,
copy: AssetSchemaCopy,
) {
return schema.superRefine((data, ctx) => {
if (data.status === "ASSIGNED" && !data.personId) {
ctx.addIssue({
code: "custom",
path: ["personId"],
message: copy.personRequired,
})
}
}) })
} }
export const assetSchema = buildAssetBaseSchema(defaultAssetSchemaCopy) export const assetSchema = buildAssetBaseSchema(defaultAssetSchemaCopy)
export function buildCreateAssetSchema(copy: AssetSchemaCopy) { export function buildCreateAssetSchema(copy: AssetSchemaCopy) {
return buildAssetBaseSchema(copy).extend({ return requirePersonWhenAssigned(
status: z.enum(createAssetStatuses, { buildAssetBaseSchema(copy).extend({
error: (issue) => status: z.enum(CREATE_ASSET_STATUSES, {
issue.input === undefined || issue.input === "" error: (issue) =>
? copy.statusRequired issue.input === undefined || issue.input === ""
: copy.invalidCreateStatus, ? copy.statusRequired
: copy.invalidCreateStatus,
}),
}), }),
}) copy,
)
} }
export const createAssetSchema = buildCreateAssetSchema(defaultAssetSchemaCopy) export const createAssetSchema = buildCreateAssetSchema(defaultAssetSchemaCopy)
export type CreateAssetFormType = z.infer<typeof createAssetSchema> export type CreateAssetFormType = z.input<typeof createAssetSchema>
export type CreateAssetData = z.output<typeof createAssetSchema>
export function buildUpdateAssetSchema(copy: AssetSchemaCopy) { export function buildUpdateAssetSchema(copy: AssetSchemaCopy) {
return buildAssetBaseSchema(copy).extend({ return requirePersonWhenAssigned(
id: z.string().min(1, { buildAssetBaseSchema(copy).extend({
error: copy.idRequired, id: z.string().min(1, {
error: copy.idRequired,
}),
status: z.enum(UPDATE_ASSET_STATUSES, {
error: (issue) =>
issue.input === undefined || issue.input === ""
? copy.statusRequired
: copy.invalidUpdateStatus,
}),
}), }),
status: z.enum(updateAssetStatuses, { copy,
error: (issue) => )
issue.input === undefined || issue.input === ""
? copy.statusRequired
: copy.invalidUpdateStatus,
}),
})
} }
export const updateAssetSchema = buildUpdateAssetSchema(defaultAssetSchemaCopy) export const updateAssetSchema = buildUpdateAssetSchema(defaultAssetSchemaCopy)
export type UpdateAssetFormType = z.infer<typeof updateAssetSchema> export type UpdateAssetFormType = z.input<typeof updateAssetSchema>
export type UpdateAssetData = z.output<typeof updateAssetSchema>
+26 -10
View File
@@ -9,10 +9,14 @@ import type {
type AssetWithActiveAssignmentLine = Prisma.AssetGetPayload<{ type AssetWithActiveAssignmentLine = Prisma.AssetGetPayload<{
include: { include: {
item: true item: true
assignmentLines: { include: { assignment: true } } assignmentLines: { include: { assignment: { include: { person: true } } } }
} }
}> }>
const activeRecordWhere = {
deletedAt: null,
} as const
function toAssetWithAssignment( function toAssetWithAssignment(
asset: AssetWithActiveAssignmentLine | null, asset: AssetWithActiveAssignmentLine | null,
): AssetWithAssignment | null { ): AssetWithAssignment | null {
@@ -41,6 +45,7 @@ export const AssetService = {
includeCategory?: boolean includeCategory?: boolean
}) => { }) => {
return prisma.asset.findMany({ return prisma.asset.findMany({
where: activeRecordWhere,
include: { include: {
item: opts?.includeItem item: opts?.includeItem
? { ? {
@@ -54,6 +59,7 @@ export const AssetService = {
findAllAvailable: async (): Promise<Asset[]> => { findAllAvailable: async (): Promise<Asset[]> => {
return prisma.asset.findMany({ return prisma.asset.findMany({
where: { where: {
...activeRecordWhere,
status: { status: {
equals: "AVAILABLE", equals: "AVAILABLE",
}, },
@@ -62,7 +68,7 @@ export const AssetService = {
}, },
findAllAssetsCount: async (): Promise<number> => { findAllAssetsCount: async (): Promise<number> => {
return prisma.asset.count() return prisma.asset.count({ where: activeRecordWhere })
}, },
findAllWithItemAndCategory: async ({ findAllWithItemAndCategory: async ({
@@ -79,6 +85,7 @@ export const AssetService = {
page, page,
pageSize, pageSize,
where: { where: {
...activeRecordWhere,
...(search ...(search
? { ? {
OR: [ OR: [
@@ -109,6 +116,12 @@ export const AssetService = {
select: { select: {
id: true, id: true,
serialNumber: true, serialNumber: true,
assetTag: true,
manufacturer: true,
model: true,
purchaseDate: true,
purchasePrice: true,
warrantyEndsAt: true,
deliveryNote: true, deliveryNote: true,
status: true, status: true,
item: { item: {
@@ -126,13 +139,13 @@ export const AssetService = {
id: string, id: string,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<AssetWithAssignment | null> => { ): Promise<AssetWithAssignment | null> => {
const asset = await db.asset.findUnique({ const asset = await db.asset.findFirst({
where: { id }, where: { id, ...activeRecordWhere },
include: { include: {
item: true, item: true,
assignmentLines: { assignmentLines: {
where: { returnedAt: null }, where: { returnedAt: null },
include: { assignment: true }, include: { assignment: { include: { person: true } } },
orderBy: { assignedAt: "desc" }, orderBy: { assignedAt: "desc" },
take: 1, take: 1,
}, },
@@ -143,20 +156,20 @@ export const AssetService = {
}, },
findByItemId: async (itemId: string): Promise<Asset[]> => { findByItemId: async (itemId: string): Promise<Asset[]> => {
return prisma.asset.findMany({ where: { itemId } }) return prisma.asset.findMany({ where: { itemId, ...activeRecordWhere } })
}, },
findBySerialNumber: async ( findBySerialNumber: async (
serialNumber: string, serialNumber: string,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<AssetWithAssignment | null> => { ): Promise<AssetWithAssignment | null> => {
const asset = await db.asset.findUnique({ const asset = await db.asset.findFirst({
where: { serialNumber }, where: { serialNumber, ...activeRecordWhere },
include: { include: {
item: true, item: true,
assignmentLines: { assignmentLines: {
where: { returnedAt: null }, where: { returnedAt: null },
include: { assignment: true }, include: { assignment: { include: { person: true } } },
orderBy: { assignedAt: "desc" }, orderBy: { assignedAt: "desc" },
take: 1, take: 1,
}, },
@@ -182,6 +195,9 @@ export const AssetService = {
}, },
delete: async (id: string): Promise<Asset> => { delete: async (id: string): Promise<Asset> => {
return prisma.asset.delete({ where: { id } }) return prisma.asset.update({
where: { id },
data: { deletedAt: new Date() },
})
}, },
} }
+14 -2
View File
@@ -1,9 +1,11 @@
import type { import type {
Asset as PrismaAsset, Asset as PrismaAsset,
AssetStatus as PrismaAssetStatus, AssetStatus as PrismaAssetStatus,
Prisma,
} from "@/generated/prisma/client" } from "@/generated/prisma/client"
import type { Assignment } from "./assignment" import type { Assignment } from "./assignment"
import type { Person } from "./person"
export type Asset = PrismaAsset export type Asset = PrismaAsset
@@ -11,13 +13,23 @@ export type ItemStatus = PrismaAssetStatus
export type UpdateAssetStatus = PrismaAssetStatus export type UpdateAssetStatus = PrismaAssetStatus
export type AssetWithAssignment = Asset & { export type AssetWithAssignment = Prisma.AssetGetPayload<{
assignment: Assignment | null include: {
item: true
}
}> & {
assignment: (Assignment & { person: Person | null }) | null
} }
export type AssetWithItemAndCategory = { export type AssetWithItemAndCategory = {
id: string id: string
serialNumber: string serialNumber: string
assetTag?: string | null
manufacturer?: string | null
model?: string | null
purchaseDate?: Date | null
purchasePrice?: Prisma.Decimal | null
warrantyEndsAt?: Date | null
deliveryNote?: string | null deliveryNote?: string | null
status: ItemStatus status: ItemStatus
item: { item: {
+44 -10
View File
@@ -1,12 +1,6 @@
import { import { type AssetStatus, Prisma } from "@/generated/prisma/client"
type AssetStatus,
Prisma,
} from "@/generated/prisma/client"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import type { import type { CreateAssetData, UpdateAssetData } from "@/schemas/asset.schema"
CreateAssetFormType,
UpdateAssetFormType,
} from "@/schemas/asset.schema"
import { AssetService } from "@/services/asset.service" import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service" import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service" import { ItemService } from "@/services/item.service"
@@ -15,11 +9,11 @@ import type { Assignment } from "@/types"
type FieldErrors = Record<string, string[]> type FieldErrors = Record<string, string[]>
type CreateAssetUseCaseInput = CreateAssetFormType & { type CreateAssetUseCaseInput = CreateAssetData & {
actorId: string actorId: string
} }
type UpdateAssetUseCaseInput = UpdateAssetFormType & { type UpdateAssetUseCaseInput = UpdateAssetData & {
actorId: string actorId: string
} }
@@ -56,6 +50,12 @@ function updateAssetError(errors: FieldErrors): UpdateAssetUseCaseResult {
} }
} }
function validateAssignedPerson(status: AssetStatus, personId?: string) {
return status === "ASSIGNED" && !personId
? { personId: ["Person is required"] }
: null
}
class AssetTransitionError extends Error { class AssetTransitionError extends Error {
constructor(readonly errors: FieldErrors) { constructor(readonly errors: FieldErrors) {
super("Asset transition failed") super("Asset transition failed")
@@ -104,12 +104,23 @@ export async function createAssetUseCase(
actorId, actorId,
itemId, itemId,
serialNumber, serialNumber,
assetTag,
manufacturer,
model,
purchaseDate,
purchasePrice,
warrantyEndsAt,
deliveryNote, deliveryNote,
status, status,
notes, notes,
personId, personId,
} = input } = input
const assignedPersonError = validateAssignedPerson(status, personId)
if (assignedPersonError) {
return createAssetError(assignedPersonError)
}
try { try {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const item = await ItemService.findByIdWithCategory(itemId, tx) const item = await ItemService.findByIdWithCategory(itemId, tx)
@@ -133,6 +144,12 @@ export async function createAssetUseCase(
{ {
item: { connect: { id: itemId } }, item: { connect: { id: itemId } },
serialNumber, serialNumber,
assetTag,
manufacturer,
model,
purchaseDate,
purchasePrice,
warrantyEndsAt,
deliveryNote, deliveryNote,
status, status,
notes, notes,
@@ -200,12 +217,23 @@ export async function updateAssetUseCase(
id, id,
itemId, itemId,
serialNumber, serialNumber,
assetTag,
manufacturer,
model,
purchaseDate,
purchasePrice,
warrantyEndsAt,
deliveryNote, deliveryNote,
status, status,
notes, notes,
personId, personId,
} = input } = input
const assignedPersonError = validateAssignedPerson(status, personId)
if (assignedPersonError) {
return updateAssetError(assignedPersonError)
}
try { try {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const item = await ItemService.findByIdWithCategory(itemId, tx) const item = await ItemService.findByIdWithCategory(itemId, tx)
@@ -249,6 +277,12 @@ export async function updateAssetUseCase(
{ {
item: { connect: { id: itemId } }, item: { connect: { id: itemId } },
serialNumber, serialNumber,
assetTag,
manufacturer,
model,
purchaseDate,
purchasePrice,
warrantyEndsAt,
deliveryNote, deliveryNote,
status, status,
notes, notes,
@@ -14,16 +14,19 @@ import {
let prisma: PrismaClient let prisma: PrismaClient
let createAssetUseCase: typeof import("@/use-cases/asset.use-cases").createAssetUseCase let createAssetUseCase: typeof import("@/use-cases/asset.use-cases").createAssetUseCase
let updateAssetUseCase: typeof import("@/use-cases/asset.use-cases").updateAssetUseCase let updateAssetUseCase: typeof import("@/use-cases/asset.use-cases").updateAssetUseCase
let AssetService: typeof import("@/services/asset.service").AssetService
beforeAll(async () => { beforeAll(async () => {
await startIntegrationTestDatabase() await startIntegrationTestDatabase()
const prismaModule = await import("@/lib/prisma") const prismaModule = await import("@/lib/prisma")
const assetUseCases = await import("@/use-cases/asset.use-cases") const assetUseCases = await import("@/use-cases/asset.use-cases")
const assetService = await import("@/services/asset.service")
prisma = prismaModule.prisma prisma = prismaModule.prisma
createAssetUseCase = assetUseCases.createAssetUseCase createAssetUseCase = assetUseCases.createAssetUseCase
updateAssetUseCase = assetUseCases.updateAssetUseCase updateAssetUseCase = assetUseCases.updateAssetUseCase
AssetService = assetService.AssetService
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -74,8 +77,110 @@ describe("asset use-cases", () => {
type: "RECEIPT", type: "RECEIPT",
performedById: actor.id, performedById: actor.id,
}) })
expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 }) expect(movements[0].stockLines[0]).toMatchObject({
expect(movements[0].assetLines[0]).toMatchObject({ assetId: result.assetId }) itemId: item.id,
stockDelta: 1,
})
expect(movements[0].assetLines[0]).toMatchObject({
assetId: result.assetId,
})
})
it("persists operational asset fields during create and update flows", async () => {
const actor = await createTestUser(prisma)
const item = await createTestItem(prisma, { stock: 0 })
const created = await createAssetUseCase({
actorId: actor.id,
itemId: item.id,
serialNumber: "ASSET-OPS-001",
status: "AVAILABLE",
assetTag: "IT-000777",
manufacturer: "Lenovo",
model: "ThinkPad X1",
purchaseDate: new Date("2026-01-15T00:00:00.000Z"),
purchasePrice: 1249.99,
warrantyEndsAt: new Date("2028-01-15T00:00:00.000Z"),
})
expect(created.success).toBe(true)
if (!created.success) throw new Error("Expected asset creation success")
const createdAsset = await prisma.asset.findUniqueOrThrow({
where: { id: created.assetId },
})
expect(createdAsset).toMatchObject({
assetTag: "IT-000777",
manufacturer: "Lenovo",
model: "ThinkPad X1",
})
expect(createdAsset.purchaseDate?.toISOString()).toBe(
"2026-01-15T00:00:00.000Z",
)
expect(createdAsset.purchasePrice?.toString()).toBe("1249.99")
expect(createdAsset.warrantyEndsAt?.toISOString()).toBe(
"2028-01-15T00:00:00.000Z",
)
const updated = await updateAssetUseCase({
actorId: actor.id,
id: created.assetId,
itemId: item.id,
serialNumber: "ASSET-OPS-001",
status: "BROKEN",
assetTag: "IT-000778",
manufacturer: "Dell",
model: "Latitude 7420",
purchaseDate: new Date("2026-02-01T00:00:00.000Z"),
purchasePrice: 1499.5,
warrantyEndsAt: new Date("2027-02-01T00:00:00.000Z"),
})
expect(updated.success).toBe(true)
const updatedAsset = await prisma.asset.findUniqueOrThrow({
where: { id: created.assetId },
})
expect(updatedAsset).toMatchObject({
assetTag: "IT-000778",
manufacturer: "Dell",
model: "Latitude 7420",
status: "BROKEN",
})
expect(updatedAsset.purchaseDate?.toISOString()).toBe(
"2026-02-01T00:00:00.000Z",
)
expect(updatedAsset.purchasePrice?.toString()).toBe("1499.5")
expect(updatedAsset.warrantyEndsAt?.toISOString()).toBe(
"2027-02-01T00:00:00.000Z",
)
})
it("soft deletes assets and excludes them from active queries", async () => {
const actor = await createTestUser(prisma)
const item = await createTestItem(prisma, { stock: 0 })
const created = await createAssetUseCase({
actorId: actor.id,
itemId: item.id,
serialNumber: "ASSET-SOFT-DELETE-001",
status: "AVAILABLE",
})
expect(created.success).toBe(true)
if (!created.success) throw new Error("Expected asset creation success")
await AssetService.delete(created.assetId)
const deletedAsset = await prisma.asset.findUniqueOrThrow({
where: { id: created.assetId },
})
expect(deletedAsset.deletedAt).toBeInstanceOf(Date)
expect(await AssetService.findById(created.assetId)).toBeNull()
expect(await AssetService.findAllAssetsCount()).toBe(0)
expect(await AssetService.findAll()).toHaveLength(0)
}) })
it("creates an assigned asset with assignment and ASSIGNMENT movement", async () => { it("creates an assigned asset with assignment and ASSIGNMENT movement", async () => {
@@ -128,8 +233,31 @@ describe("asset use-cases", () => {
assignmentId: assignment.id, assignmentId: assignment.id,
performedById: actor.id, performedById: actor.id,
}) })
expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -1 }) expect(movements[0].stockLines[0]).toMatchObject({
expect(movements[0].assetLines[0]).toMatchObject({ assetId: result.assetId }) itemId: item.id,
stockDelta: -1,
})
expect(movements[0].assetLines[0]).toMatchObject({
assetId: result.assetId,
})
})
it("rejects creating an assigned asset without a person", async () => {
const actor = await createTestUser(prisma)
const item = await createTestItem(prisma, { stock: 0 })
const result = await createAssetUseCase({
actorId: actor.id,
itemId: item.id,
serialNumber: "ASSET-ASSIGNED-NO-PERSON-001",
status: "ASSIGNED",
})
expect(result).toEqual({
success: false,
errors: { personId: ["Person is required"] },
})
await expect(prisma.asset.count()).resolves.toBe(0)
}) })
it("moves an available asset to assigned and back to available", async () => { it("moves an available asset to assigned and back to available", async () => {
@@ -175,7 +303,9 @@ describe("asset use-cases", () => {
expect(activeAssignment).toMatchObject({ expect(activeAssignment).toMatchObject({
personId: person.id, personId: person.id,
}) })
expect(activeAssignment.assetLines[0]).toMatchObject({ assetId: created.assetId }) expect(activeAssignment.assetLines[0]).toMatchObject({
assetId: created.assetId,
})
await expect( await expect(
updateAssetUseCase({ updateAssetUseCase({
@@ -217,14 +347,57 @@ describe("asset use-cases", () => {
assignmentId: activeAssignment.id, assignmentId: activeAssignment.id,
performedById: actor.id, performedById: actor.id,
}) })
expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -1 }) expect(movements[1].stockLines[0]).toMatchObject({
expect(movements[1].assetLines[0]).toMatchObject({ assetId: created.assetId }) itemId: item.id,
stockDelta: -1,
})
expect(movements[1].assetLines[0]).toMatchObject({
assetId: created.assetId,
})
expect(movements[2]).toMatchObject({ expect(movements[2]).toMatchObject({
assignmentId: activeAssignment.id, assignmentId: activeAssignment.id,
performedById: actor.id, performedById: actor.id,
}) })
expect(movements[2].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 }) expect(movements[2].stockLines[0]).toMatchObject({
expect(movements[2].assetLines[0]).toMatchObject({ assetId: created.assetId }) itemId: item.id,
stockDelta: 1,
})
expect(movements[2].assetLines[0]).toMatchObject({
assetId: created.assetId,
})
})
it("rejects updating an asset to assigned without a person", async () => {
const actor = await createTestUser(prisma)
const item = await createTestItem(prisma, { stock: 0 })
const created = await createAssetUseCase({
actorId: actor.id,
itemId: item.id,
serialNumber: "ASSET-UPDATE-ASSIGNED-NO-PERSON-001",
status: "AVAILABLE",
})
expect(created.success).toBe(true)
if (!created.success) throw new Error("Expected asset creation success")
const result = await updateAssetUseCase({
actorId: actor.id,
id: created.assetId,
itemId: item.id,
serialNumber: "ASSET-UPDATE-ASSIGNED-NO-PERSON-001",
status: "ASSIGNED",
})
expect(result).toEqual({
success: false,
errors: { personId: ["Person is required"] },
})
const asset = await prisma.asset.findUniqueOrThrow({
where: { id: created.assetId },
})
expect(asset.status).toBe("AVAILABLE")
}) })
it("returns an active assignment without restoring stock when an assigned asset moves to a terminal status", async () => { it("returns an active assignment without restoring stock when an assigned asset moves to a terminal status", async () => {
@@ -286,7 +459,12 @@ describe("asset use-cases", () => {
assignmentId: activeAssignment.id, assignmentId: activeAssignment.id,
performedById: actor.id, performedById: actor.id,
}) })
expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 }) expect(movements[1].stockLines[0]).toMatchObject({
expect(movements[1].assetLines[0]).toMatchObject({ assetId: created.assetId }) itemId: item.id,
stockDelta: 1,
})
expect(movements[1].assetLines[0]).toMatchObject({
assetId: created.assetId,
})
}) })
}) })
+113
View File
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
const mocks = vi.hoisted(() => ({
revalidatePath: vi.fn(),
getI18n: vi.fn(),
getAuthenticatedUserId: vi.fn(),
createAssetUseCase: vi.fn(),
updateAssetUseCase: vi.fn(),
}))
vi.mock("next/cache", () => ({
revalidatePath: mocks.revalidatePath,
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/auth.service", () => ({
getAuthenticatedUserId: mocks.getAuthenticatedUserId,
}))
vi.mock("@/use-cases/asset.use-cases", () => ({
createAssetUseCase: mocks.createAssetUseCase,
updateAssetUseCase: mocks.updateAssetUseCase,
}))
import { createAssetAction, updateAssetAction } from "@/actions/asset.actions"
describe("asset actions", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
mocks.getAuthenticatedUserId.mockResolvedValue("user-1")
})
it("accepts operational asset fields on create and forwards them to the use case", async () => {
mocks.createAssetUseCase.mockResolvedValue({
success: true,
assetId: "asset-1",
})
const result = await createAssetAction({
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "AVAILABLE",
assetTag: "IT-000900",
manufacturer: "Lenovo",
model: "ThinkPad P1",
purchaseDate: "2026-01-15",
purchasePrice: "1400.25",
warrantyEndsAt: "2028-01-15",
})
expect(result).toEqual({
success: true,
message: en.inventory.assets.actions.createSuccess,
})
expect(mocks.createAssetUseCase).toHaveBeenCalledWith({
actorId: "user-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "AVAILABLE",
assetTag: "IT-000900",
manufacturer: "Lenovo",
model: "ThinkPad P1",
purchaseDate: new Date("2026-01-15T00:00:00.000Z"),
purchasePrice: 1400.25,
warrantyEndsAt: new Date("2028-01-15T00:00:00.000Z"),
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/inventory/assets")
expect(mocks.revalidatePath).toHaveBeenCalledWith("/inventory/items")
})
it("accepts operational asset fields on update and forwards them to the use case", async () => {
mocks.updateAssetUseCase.mockResolvedValue({ success: true })
const result = await updateAssetAction({
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "BROKEN",
assetTag: "IT-000901",
manufacturer: "Dell",
model: "Latitude 7420",
purchaseDate: "2026-02-01",
purchasePrice: "1499.5",
warrantyEndsAt: "2027-02-01",
})
expect(result).toEqual({
success: true,
message: en.inventory.assets.actions.updateSuccess,
})
expect(mocks.updateAssetUseCase).toHaveBeenCalledWith({
actorId: "user-1",
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "BROKEN",
assetTag: "IT-000901",
manufacturer: "Dell",
model: "Latitude 7420",
purchaseDate: new Date("2026-02-01T00:00:00.000Z"),
purchasePrice: 1499.5,
warrantyEndsAt: new Date("2027-02-01T00:00:00.000Z"),
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/inventory/assets")
expect(mocks.revalidatePath).toHaveBeenCalledWith("/inventory/items")
})
})
@@ -0,0 +1,123 @@
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
getI18n: vi.fn(),
findById: vi.fn(),
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/asset.service", () => ({
AssetService: {
findById: mocks.findById,
},
}))
vi.mock("@/components/common/pageheader", () => ({
default: ({ title }: { title: string }) =>
createElement("header", null, title),
}))
vi.mock("@/components/ui/button", () => ({
Button: ({ children }: { children: React.ReactNode }) =>
createElement("button", null, children),
}))
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) =>
createElement("a", { href }, children),
}))
describe("asset detail page", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({
dictionary: {
inventory: {
assets: {
detail: {
title: "Asset Details",
notFound: "Asset not found",
actions: { edit: "Edit asset" },
labels: {
item: "Item",
serialNumber: "Serial Number",
assetTag: "Asset Tag",
manufacturer: "Manufacturer",
model: "Model",
purchaseDate: "Purchase Date",
purchasePrice: "Purchase Price",
warrantyEndsAt: "Warranty Ends At",
deliveryNote: "Delivery Note",
notes: "Notes",
status: "Status",
person: "Person",
},
},
list: {
title: "Assets",
addLabel: "Add Asset",
empty: "No assets found.",
columns: {},
actions: {},
},
new: { title: "New Asset" },
edit: { title: "Edit Asset", notFound: "Asset not found" },
form: {},
status: { AVAILABLE: "Available" },
fallback: { unknownStatus: "Unknown status" },
actions: {},
schema: {},
},
},
common: { submitButton: {} },
},
locale: "en",
})
})
it("renders the asset operational metadata in the detail view", async () => {
mocks.findById.mockResolvedValue({
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
assetTag: "IT-000777",
manufacturer: "Lenovo",
model: "ThinkPad X1",
purchaseDate: new Date("2026-01-15T00:00:00.000Z"),
purchasePrice: 1249.99,
warrantyEndsAt: new Date("2028-01-15T00:00:00.000Z"),
deliveryNote: "DN-1",
notes: "Ready",
status: "AVAILABLE",
item: { name: "Laptop" },
assignment: {
person: {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
},
},
})
const { default: AssetDetailPage } = await import(
"@/app/(dashboard)/inventory/assets/[assetId]/page"
)
const html = renderToStaticMarkup(
await AssetDetailPage({ params: Promise.resolve({ assetId: "asset-1" }) }),
)
expect(html).toContain("Asset Details")
expect(html).toContain("Asset Tag")
expect(html).toContain("IT-000777")
expect(html).toContain("Manufacturer")
expect(html).toContain("Lenovo")
expect(html).toContain("Ada Lovelace")
expect(html).toContain("Purchase Price")
})
})
@@ -0,0 +1,119 @@
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
getI18n: vi.fn(),
findAllWithItemAndCategory: vi.fn(),
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/asset.service", () => ({
AssetService: {
findAllWithItemAndCategory: mocks.findAllWithItemAndCategory,
},
}))
vi.mock("@/components/common/pageheader", () => ({
default: ({ title, link }: { title: string; link: string }) =>
createElement("header", null, title, link),
}))
vi.mock("@/components/common/pagination", () => ({
default: ({ totalPages }: { totalPages: number }) =>
createElement("div", null, `pages:${totalPages}`),
}))
vi.mock("@/components/ui/button", () => ({
Button: ({ children }: { children: React.ReactNode }) =>
createElement("button", null, children),
}))
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) =>
createElement("a", { href }, children),
}))
describe("assets page", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({
dictionary: {
inventory: {
assets: {
list: {
title: "Assets",
addLabel: "Add Asset",
empty: "No assets found.",
columns: {
item: "Item",
category: "Category",
serialNumber: "Serial Number",
assetTag: "Asset Tag",
manufacturer: "Manufacturer",
model: "Model",
purchaseDate: "Purchase Date",
purchasePrice: "Purchase Price",
warrantyEndsAt: "Warranty Ends At",
status: "Status",
actions: "Actions",
},
actions: { view: "View asset", edit: "Edit asset" },
},
new: { title: "New Asset" },
edit: { title: "Edit Asset", notFound: "Asset not found" },
form: {},
status: { AVAILABLE: "Available" },
fallback: { unknownStatus: "Unknown status" },
actions: {},
schema: {},
detail: {
title: "Asset Details",
notFound: "Asset not found",
labels: {},
},
},
},
common: { submitButton: {} },
},
locale: "en",
})
})
it("renders asset operational columns in the list", async () => {
mocks.findAllWithItemAndCategory.mockResolvedValue({
data: [
{
id: "asset-1",
item: { name: "Laptop", category: { name: "Devices" } },
serialNumber: "SERIAL-1",
assetTag: "IT-000777",
manufacturer: "Lenovo",
model: "ThinkPad X1",
purchaseDate: new Date("2026-01-15T00:00:00.000Z"),
purchasePrice: 1249.99,
warrantyEndsAt: new Date("2028-01-15T00:00:00.000Z"),
status: "AVAILABLE",
},
],
totalPages: 1,
})
const { default: AssetsPage } = await import(
"@/app/(dashboard)/inventory/assets/page"
)
const html = renderToStaticMarkup(
await AssetsPage({ searchParams: Promise.resolve({}) }),
)
expect(html).toContain("Asset Tag")
expect(html).toContain("Manufacturer")
expect(html).toContain("Model")
expect(html).toContain("IT-000777")
expect(html).toContain("ThinkPad X1")
})
})
@@ -0,0 +1,202 @@
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
updateAssetAction: vi.fn(),
}))
vi.mock("next/navigation", () => ({
useRouter: mocks.useRouter,
}))
vi.mock("@/actions/asset.actions", () => ({
updateAssetAction: mocks.updateAssetAction,
}))
describe("edit asset form", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders the operational asset fields and current values on the edit form", async () => {
const { default: EditAssetForm } = await import(
"@/app/(dashboard)/inventory/assets/_components/edit.asset.form"
)
const html = renderToStaticMarkup(
createElement(EditAssetForm, {
asset: {
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "AVAILABLE",
assetTag: "IT-000777",
manufacturer: "Lenovo",
model: "ThinkPad X1",
purchaseDate: new Date("2026-01-15T00:00:00.000Z"),
purchasePrice: 1249.99,
warrantyEndsAt: new Date("2028-01-15T00:00:00.000Z"),
deliveryNote: null,
notes: null,
personId: null,
item: null,
assignment: null,
},
items: [{ id: "item-1", name: "Laptop" }],
people: [{ id: "person-1", firstName: "Ada", lastName: "Lovelace" }],
formCopy: {
itemLabel: "Item",
itemPlaceholder: "Select an item",
serialNumberLabel: "Serial Number",
serialNumberPlaceholder: "Serial number",
deliveryNoteLabel: "Delivery Note",
deliveryNotePlaceholder: "Delivery note",
statusLabel: "Status",
statusPlaceholder: "Select a status",
personLabel: "Person",
personPlaceholder: "Select a person",
assetTagLabel: "Asset Tag",
assetTagPlaceholder: "Asset tag",
manufacturerLabel: "Manufacturer",
manufacturerPlaceholder: "Manufacturer",
modelLabel: "Model",
modelPlaceholder: "Model",
purchaseDateLabel: "Purchase Date",
purchaseDatePlaceholder: "YYYY-MM-DD",
purchasePriceLabel: "Purchase Price",
purchasePricePlaceholder: "0.00",
warrantyEndsAtLabel: "Warranty Ends At",
warrantyEndsAtPlaceholder: "YYYY-MM-DD",
createSubmit: "Create Asset",
updateSubmit: "Update Asset",
},
schemaCopy: {
itemRequired: "Item is required",
serialNumberRequired: "Serial number is required",
idRequired: "ID is required",
statusRequired: "Status is required",
invalidCreateStatus: "Status must be Available or Assigned",
invalidUpdateStatus: "Invalid status",
personRequired: "Person is required",
},
statusCopy: {
AVAILABLE: "Available",
ASSIGNED: "Assigned",
IN_REPAIR: "In repair",
BROKEN: "Broken",
LOST: "Lost",
STOLEN: "Stolen",
DISPOSED: "Disposed",
RETIRED: "Retired",
},
submitButtonCopy: {
defaultLabel: "Submit",
processing: "Processing",
success: "Success",
},
}),
)
expect(html).toContain("Asset Tag")
expect(html).toContain("IT-000777")
expect(html).toContain("Manufacturer")
expect(html).toContain("Lenovo")
expect(html).toContain("Purchase Price")
})
it("exposes every backend-supported update status", async () => {
const { default: EditAssetForm } = await import(
"@/app/(dashboard)/inventory/assets/_components/edit.asset.form"
)
const html = renderToStaticMarkup(
createElement(EditAssetForm, {
asset: {
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "AVAILABLE",
assetTag: null,
manufacturer: null,
model: null,
purchaseDate: null,
purchasePrice: null,
warrantyEndsAt: null,
deliveryNote: null,
notes: null,
personId: null,
item: null,
assignment: null,
},
items: [{ id: "item-1", name: "Laptop" }],
people: [],
formCopy: {
itemLabel: "Item",
itemPlaceholder: "Select an item",
serialNumberLabel: "Serial Number",
serialNumberPlaceholder: "Serial number",
deliveryNoteLabel: "Delivery Note",
deliveryNotePlaceholder: "Delivery note",
statusLabel: "Status",
statusPlaceholder: "Select a status",
personLabel: "Person",
personPlaceholder: "Select a person",
assetTagLabel: "Asset Tag",
assetTagPlaceholder: "Asset tag",
manufacturerLabel: "Manufacturer",
manufacturerPlaceholder: "Manufacturer",
modelLabel: "Model",
modelPlaceholder: "Model",
purchaseDateLabel: "Purchase Date",
purchaseDatePlaceholder: "YYYY-MM-DD",
purchasePriceLabel: "Purchase Price",
purchasePricePlaceholder: "0.00",
warrantyEndsAtLabel: "Warranty Ends At",
warrantyEndsAtPlaceholder: "YYYY-MM-DD",
createSubmit: "Create Asset",
updateSubmit: "Update Asset",
},
schemaCopy: {
itemRequired: "Item is required",
serialNumberRequired: "Serial number is required",
idRequired: "ID is required",
statusRequired: "Status is required",
invalidCreateStatus: "Status must be Available or Assigned",
invalidUpdateStatus: "Invalid status",
personRequired: "Person is required",
},
statusCopy: {
AVAILABLE: "Available",
ASSIGNED: "Assigned",
IN_REPAIR: "In repair",
BROKEN: "Broken",
LOST: "Lost",
STOLEN: "Stolen",
DISPOSED: "Disposed",
RETIRED: "Retired",
},
submitButtonCopy: {
defaultLabel: "Submit",
processing: "Processing",
success: "Success",
},
}),
)
for (const status of [
"AVAILABLE",
"ASSIGNED",
"IN_REPAIR",
"BROKEN",
"LOST",
"STOLEN",
"DISPOSED",
"RETIRED",
]) {
expect(html).toContain(`value="${status}"`)
}
expect(html).not.toContain('value="RESERVED"')
})
})
@@ -0,0 +1,160 @@
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
createAssetAction: vi.fn(),
}))
vi.mock("next/navigation", () => ({
useRouter: mocks.useRouter,
}))
vi.mock("@/actions/asset.actions", () => ({
createAssetAction: mocks.createAssetAction,
}))
describe("new asset form", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders the operational asset fields on the create form", async () => {
const { default: NewAssetForm } = await import(
"@/app/(dashboard)/inventory/assets/_components/new.asset.form"
)
const html = renderToStaticMarkup(
createElement(NewAssetForm, {
items: [{ id: "item-1", name: "Laptop" }],
people: [{ id: "person-1", firstName: "Ada", lastName: "Lovelace" }],
formCopy: {
itemLabel: "Item",
itemPlaceholder: "Select an item",
serialNumberLabel: "Serial Number",
serialNumberPlaceholder: "Serial number",
deliveryNoteLabel: "Delivery Note",
deliveryNotePlaceholder: "Delivery note",
statusLabel: "Status",
statusPlaceholder: "Select a status",
personLabel: "Person",
personPlaceholder: "Select a person",
assetTagLabel: "Asset Tag",
assetTagPlaceholder: "Asset tag",
manufacturerLabel: "Manufacturer",
manufacturerPlaceholder: "Manufacturer",
modelLabel: "Model",
modelPlaceholder: "Model",
purchaseDateLabel: "Purchase Date",
purchaseDatePlaceholder: "YYYY-MM-DD",
purchasePriceLabel: "Purchase Price",
purchasePricePlaceholder: "0.00",
warrantyEndsAtLabel: "Warranty Ends At",
warrantyEndsAtPlaceholder: "YYYY-MM-DD",
createSubmit: "Create Asset",
updateSubmit: "Update Asset",
},
schemaCopy: {
itemRequired: "Item is required",
serialNumberRequired: "Serial number is required",
idRequired: "ID is required",
statusRequired: "Status is required",
invalidCreateStatus: "Status must be Available or Assigned",
invalidUpdateStatus: "Invalid status",
personRequired: "Person is required",
},
statusCopy: {
AVAILABLE: "Available",
ASSIGNED: "Assigned",
IN_REPAIR: "In repair",
BROKEN: "Broken",
LOST: "Lost",
STOLEN: "Stolen",
DISPOSED: "Disposed",
RETIRED: "Retired",
},
submitButtonCopy: {
defaultLabel: "Submit",
processing: "Processing",
success: "Success",
},
}),
)
expect(html).toContain("Asset Tag")
expect(html).toContain("Manufacturer")
expect(html).toContain("Model")
expect(html).toContain("Purchase Date")
expect(html).toContain("Purchase Price")
expect(html).toContain("Warranty Ends At")
})
it("only exposes create-supported asset statuses", async () => {
const { default: NewAssetForm } = await import(
"@/app/(dashboard)/inventory/assets/_components/new.asset.form"
)
const html = renderToStaticMarkup(
createElement(NewAssetForm, {
items: [{ id: "item-1", name: "Laptop" }],
people: [],
formCopy: {
itemLabel: "Item",
itemPlaceholder: "Select an item",
serialNumberLabel: "Serial Number",
serialNumberPlaceholder: "Serial number",
deliveryNoteLabel: "Delivery Note",
deliveryNotePlaceholder: "Delivery note",
statusLabel: "Status",
statusPlaceholder: "Select a status",
personLabel: "Person",
personPlaceholder: "Select a person",
assetTagLabel: "Asset Tag",
assetTagPlaceholder: "Asset tag",
manufacturerLabel: "Manufacturer",
manufacturerPlaceholder: "Manufacturer",
modelLabel: "Model",
modelPlaceholder: "Model",
purchaseDateLabel: "Purchase Date",
purchaseDatePlaceholder: "YYYY-MM-DD",
purchasePriceLabel: "Purchase Price",
purchasePricePlaceholder: "0.00",
warrantyEndsAtLabel: "Warranty Ends At",
warrantyEndsAtPlaceholder: "YYYY-MM-DD",
createSubmit: "Create Asset",
updateSubmit: "Update Asset",
},
schemaCopy: {
itemRequired: "Item is required",
serialNumberRequired: "Serial number is required",
idRequired: "ID is required",
statusRequired: "Status is required",
invalidCreateStatus: "Status must be Available or Assigned",
invalidUpdateStatus: "Invalid status",
personRequired: "Person is required",
},
statusCopy: {
AVAILABLE: "Available",
ASSIGNED: "Assigned",
IN_REPAIR: "In repair",
BROKEN: "Broken",
LOST: "Lost",
STOLEN: "Stolen",
DISPOSED: "Disposed",
RETIRED: "Retired",
},
submitButtonCopy: {
defaultLabel: "Submit",
processing: "Processing",
success: "Success",
},
}),
)
expect(html).toContain('value="AVAILABLE"')
expect(html).toContain('value="ASSIGNED"')
expect(html).not.toContain('value="IN_REPAIR"')
expect(html).not.toContain('value="BROKEN"')
})
})
+119
View File
@@ -12,6 +12,7 @@ const schemaCopy = {
statusRequired: "El estado es obligatorio", statusRequired: "El estado es obligatorio",
invalidCreateStatus: "El estado inicial no es válido", invalidCreateStatus: "El estado inicial no es válido",
invalidUpdateStatus: "El estado no es válido", invalidUpdateStatus: "El estado no es válido",
personRequired: "La persona es obligatoria",
} }
describe("asset schema localization", () => { describe("asset schema localization", () => {
@@ -75,6 +76,65 @@ describe("asset schema localization", () => {
} }
}) })
it("requires person when create or update status is assigned", () => {
const createResult = buildCreateAssetSchema(schemaCopy).safeParse({
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "ASSIGNED",
})
expect(createResult.success).toBe(false)
if (!createResult.success) {
expect(createResult.error.flatten().fieldErrors.personId).toContain(
schemaCopy.personRequired,
)
}
const updateResult = buildUpdateAssetSchema(schemaCopy).safeParse({
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "ASSIGNED",
})
expect(updateResult.success).toBe(false)
if (!updateResult.success) {
expect(updateResult.error.flatten().fieldErrors.personId).toContain(
schemaCopy.personRequired,
)
}
})
it("aligns create and update status options with the asset status contract", () => {
expect(
buildCreateAssetSchema(schemaCopy).safeParse({
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "IN_REPAIR",
}).success,
).toBe(false)
for (const status of ["LOST", "RETIRED"] as const) {
expect(
buildUpdateAssetSchema(schemaCopy).safeParse({
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
status,
}).success,
).toBe(true)
}
expect(
buildUpdateAssetSchema(schemaCopy).safeParse({
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-1",
status: "RESERVED",
}).success,
).toBe(false)
})
it("keeps optional asset fields optional", () => { it("keeps optional asset fields optional", () => {
const result = buildCreateAssetSchema(schemaCopy).safeParse({ const result = buildCreateAssetSchema(schemaCopy).safeParse({
itemId: "item-1", itemId: "item-1",
@@ -84,4 +144,63 @@ describe("asset schema localization", () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
it("accepts operational asset fields on create and update payloads", () => {
const createResult = buildCreateAssetSchema(schemaCopy).safeParse({
itemId: "item-1",
serialNumber: "SERIAL-2",
status: "AVAILABLE",
assetTag: "IT-000123",
manufacturer: "Lenovo",
model: "ThinkPad T14",
purchaseDate: "2026-01-15",
purchasePrice: "1249.99",
warrantyEndsAt: "2028-01-15",
})
expect(createResult.success).toBe(true)
if (createResult.success) {
expect(createResult.data).toMatchObject({
assetTag: "IT-000123",
manufacturer: "Lenovo",
model: "ThinkPad T14",
purchasePrice: 1249.99,
})
expect(createResult.data.purchaseDate?.toISOString()).toBe(
"2026-01-15T00:00:00.000Z",
)
expect(createResult.data.warrantyEndsAt?.toISOString()).toBe(
"2028-01-15T00:00:00.000Z",
)
}
const updateResult = buildUpdateAssetSchema(schemaCopy).safeParse({
id: "asset-1",
itemId: "item-1",
serialNumber: "SERIAL-2",
status: "BROKEN",
assetTag: "IT-000124",
manufacturer: "Dell",
model: "Latitude 7420",
purchaseDate: "2026-02-01",
purchasePrice: "1499.5",
warrantyEndsAt: "2027-02-01",
})
expect(updateResult.success).toBe(true)
if (updateResult.success) {
expect(updateResult.data).toMatchObject({
assetTag: "IT-000124",
manufacturer: "Dell",
model: "Latitude 7420",
purchasePrice: 1499.5,
})
expect(updateResult.data.purchaseDate?.toISOString()).toBe(
"2026-02-01T00:00:00.000Z",
)
expect(updateResult.data.warrantyEndsAt?.toISOString()).toBe(
"2027-02-01T00:00:00.000Z",
)
}
})
}) })