feat(assets): add asset metadata views and enforce assignment transitions
This commit is contained in:
@@ -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 AssetFormCopy = Dictionary["inventory"]["assets"]["form"]
|
||||
export type AssetDetailCopy = Dictionary["inventory"]["assets"]["detail"]
|
||||
export type AssetStatusCopy = Dictionary["inventory"]["assets"]["status"]
|
||||
export type AssetFallbackCopy = Dictionary["inventory"]["assets"]["fallback"]
|
||||
export type { AssetSchemaCopy }
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import { ITEM_STATUS } from "@/lib/constants"
|
||||
import { UPDATE_ASSET_STATUSES } from "@/lib/constants"
|
||||
import {
|
||||
buildUpdateAssetSchema,
|
||||
type UpdateAssetFormType,
|
||||
@@ -102,6 +102,7 @@ export default function EditAssetForm({
|
||||
</label>
|
||||
<select
|
||||
id="itemId"
|
||||
defaultValue={asset.itemId}
|
||||
{...register("itemId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
@@ -124,6 +125,7 @@ export default function EditAssetForm({
|
||||
type="text"
|
||||
id="serialNumber"
|
||||
placeholder={formCopy.serialNumberPlaceholder}
|
||||
defaultValue={asset.serialNumber}
|
||||
{...register("serialNumber")}
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
|
||||
{formCopy.deliveryNoteLabel}
|
||||
@@ -139,6 +236,7 @@ export default function EditAssetForm({
|
||||
type="text"
|
||||
id="deliveryNote"
|
||||
placeholder={formCopy.deliveryNotePlaceholder}
|
||||
defaultValue={asset.deliveryNote ?? undefined}
|
||||
{...register("deliveryNote")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
/>
|
||||
@@ -152,11 +250,12 @@ export default function EditAssetForm({
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
defaultValue={asset.status}
|
||||
{...register("status")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">{formCopy.statusPlaceholder}</option>
|
||||
{Object.values(ITEM_STATUS).map((status) => (
|
||||
{UPDATE_ASSET_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusCopy[status]}
|
||||
</option>
|
||||
@@ -173,6 +272,7 @@ export default function EditAssetForm({
|
||||
</label>
|
||||
<select
|
||||
id="personId"
|
||||
defaultValue={asset.assignment?.personId ?? undefined}
|
||||
{...register("personId")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import { ITEM_STATUS } from "@/lib/constants"
|
||||
import { CREATE_ASSET_STATUSES } from "@/lib/constants"
|
||||
import {
|
||||
buildCreateAssetSchema,
|
||||
type CreateAssetFormType,
|
||||
@@ -119,6 +119,95 @@ export default function NewAssetForm({
|
||||
<p className="text-error">{errors?.serialNumber?.message}</p>
|
||||
)}
|
||||
</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>
|
||||
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
|
||||
{formCopy.deliveryNoteLabel}
|
||||
@@ -144,7 +233,7 @@ export default function NewAssetForm({
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">{formCopy.statusPlaceholder}</option>
|
||||
{Object.values(ITEM_STATUS).map((status) => (
|
||||
{CREATE_ASSET_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusCopy[status]}
|
||||
</option>
|
||||
|
||||
@@ -22,6 +22,14 @@ function formatAssetStatus(
|
||||
: 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: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
@@ -70,6 +78,24 @@ export default async function AssetsPage(props: {
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.serialNumber}
|
||||
</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">
|
||||
{copy.list.columns.status}
|
||||
</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?.category?.name}</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">
|
||||
{formatAssetStatus(
|
||||
asset.status,
|
||||
@@ -92,6 +124,11 @@ export default async function AssetsPage(props: {
|
||||
)}
|
||||
</td>
|
||||
<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>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -107,7 +144,7 @@ export default async function AssetsPage(props: {
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-center text-sm">
|
||||
<td colSpan={11} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+18
-1
@@ -22,9 +22,26 @@ export const PERSON_DEPARTMENTS = {
|
||||
export const ITEM_STATUS = {
|
||||
AVAILABLE: "AVAILABLE",
|
||||
ASSIGNED: "ASSIGNED",
|
||||
RESERVED: "RESERVED",
|
||||
IN_REPAIR: "IN_REPAIR",
|
||||
BROKEN: "BROKEN",
|
||||
LOST: "LOST",
|
||||
STOLEN: "STOLEN",
|
||||
DISPOSED: "DISPOSED",
|
||||
RETIRED: "RETIRED",
|
||||
} 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
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
import { CREATE_ASSET_STATUSES, UPDATE_ASSET_STATUSES } from "@/lib/constants"
|
||||
|
||||
export type AssetSchemaCopy = Dictionary["inventory"]["assets"]["schema"]
|
||||
|
||||
@@ -11,19 +12,35 @@ const defaultAssetSchemaCopy: AssetSchemaCopy = {
|
||||
statusRequired: "Status is required",
|
||||
invalidCreateStatus: "Status must be Available or Assigned",
|
||||
invalidUpdateStatus: "Invalid status",
|
||||
personRequired: "Person is required",
|
||||
}
|
||||
|
||||
const createAssetStatuses = ["AVAILABLE", "ASSIGNED"] as const
|
||||
const updateAssetStatuses = [
|
||||
"AVAILABLE",
|
||||
"ASSIGNED",
|
||||
"IN_REPAIR",
|
||||
"BROKEN",
|
||||
"LOST",
|
||||
"STOLEN",
|
||||
"DISPOSED",
|
||||
"RETIRED",
|
||||
] as const
|
||||
function buildOptionalStringSchema() {
|
||||
return z
|
||||
.preprocess(
|
||||
(value) => (value === "" || value === null ? undefined : value),
|
||||
z.string().optional(),
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
function buildOptionalDateSchema() {
|
||||
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) {
|
||||
return z.object({
|
||||
@@ -34,43 +51,72 @@ function buildAssetBaseSchema(copy: AssetSchemaCopy) {
|
||||
serialNumber: z.string().min(1, {
|
||||
error: copy.serialNumberRequired,
|
||||
}),
|
||||
assetTag: buildOptionalStringSchema(),
|
||||
manufacturer: buildOptionalStringSchema(),
|
||||
model: buildOptionalStringSchema(),
|
||||
purchaseDate: buildOptionalDateSchema(),
|
||||
purchasePrice: buildOptionalNumberSchema(),
|
||||
warrantyEndsAt: buildOptionalDateSchema(),
|
||||
deliveryNote: 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 function buildCreateAssetSchema(copy: AssetSchemaCopy) {
|
||||
return buildAssetBaseSchema(copy).extend({
|
||||
status: z.enum(createAssetStatuses, {
|
||||
error: (issue) =>
|
||||
issue.input === undefined || issue.input === ""
|
||||
? copy.statusRequired
|
||||
: copy.invalidCreateStatus,
|
||||
return requirePersonWhenAssigned(
|
||||
buildAssetBaseSchema(copy).extend({
|
||||
status: z.enum(CREATE_ASSET_STATUSES, {
|
||||
error: (issue) =>
|
||||
issue.input === undefined || issue.input === ""
|
||||
? copy.statusRequired
|
||||
: copy.invalidCreateStatus,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
copy,
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
return buildAssetBaseSchema(copy).extend({
|
||||
id: z.string().min(1, {
|
||||
error: copy.idRequired,
|
||||
return requirePersonWhenAssigned(
|
||||
buildAssetBaseSchema(copy).extend({
|
||||
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, {
|
||||
error: (issue) =>
|
||||
issue.input === undefined || issue.input === ""
|
||||
? copy.statusRequired
|
||||
: copy.invalidUpdateStatus,
|
||||
}),
|
||||
})
|
||||
copy,
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -9,10 +9,14 @@ import type {
|
||||
type AssetWithActiveAssignmentLine = Prisma.AssetGetPayload<{
|
||||
include: {
|
||||
item: true
|
||||
assignmentLines: { include: { assignment: true } }
|
||||
assignmentLines: { include: { assignment: { include: { person: true } } } }
|
||||
}
|
||||
}>
|
||||
|
||||
const activeRecordWhere = {
|
||||
deletedAt: null,
|
||||
} as const
|
||||
|
||||
function toAssetWithAssignment(
|
||||
asset: AssetWithActiveAssignmentLine | null,
|
||||
): AssetWithAssignment | null {
|
||||
@@ -41,6 +45,7 @@ export const AssetService = {
|
||||
includeCategory?: boolean
|
||||
}) => {
|
||||
return prisma.asset.findMany({
|
||||
where: activeRecordWhere,
|
||||
include: {
|
||||
item: opts?.includeItem
|
||||
? {
|
||||
@@ -54,6 +59,7 @@ export const AssetService = {
|
||||
findAllAvailable: async (): Promise<Asset[]> => {
|
||||
return prisma.asset.findMany({
|
||||
where: {
|
||||
...activeRecordWhere,
|
||||
status: {
|
||||
equals: "AVAILABLE",
|
||||
},
|
||||
@@ -62,7 +68,7 @@ export const AssetService = {
|
||||
},
|
||||
|
||||
findAllAssetsCount: async (): Promise<number> => {
|
||||
return prisma.asset.count()
|
||||
return prisma.asset.count({ where: activeRecordWhere })
|
||||
},
|
||||
|
||||
findAllWithItemAndCategory: async ({
|
||||
@@ -79,6 +85,7 @@ export const AssetService = {
|
||||
page,
|
||||
pageSize,
|
||||
where: {
|
||||
...activeRecordWhere,
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
@@ -109,6 +116,12 @@ export const AssetService = {
|
||||
select: {
|
||||
id: true,
|
||||
serialNumber: true,
|
||||
assetTag: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
purchaseDate: true,
|
||||
purchasePrice: true,
|
||||
warrantyEndsAt: true,
|
||||
deliveryNote: true,
|
||||
status: true,
|
||||
item: {
|
||||
@@ -126,13 +139,13 @@ export const AssetService = {
|
||||
id: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<AssetWithAssignment | null> => {
|
||||
const asset = await db.asset.findUnique({
|
||||
where: { id },
|
||||
const asset = await db.asset.findFirst({
|
||||
where: { id, ...activeRecordWhere },
|
||||
include: {
|
||||
item: true,
|
||||
assignmentLines: {
|
||||
where: { returnedAt: null },
|
||||
include: { assignment: true },
|
||||
include: { assignment: { include: { person: true } } },
|
||||
orderBy: { assignedAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
@@ -143,20 +156,20 @@ export const AssetService = {
|
||||
},
|
||||
|
||||
findByItemId: async (itemId: string): Promise<Asset[]> => {
|
||||
return prisma.asset.findMany({ where: { itemId } })
|
||||
return prisma.asset.findMany({ where: { itemId, ...activeRecordWhere } })
|
||||
},
|
||||
|
||||
findBySerialNumber: async (
|
||||
serialNumber: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<AssetWithAssignment | null> => {
|
||||
const asset = await db.asset.findUnique({
|
||||
where: { serialNumber },
|
||||
const asset = await db.asset.findFirst({
|
||||
where: { serialNumber, ...activeRecordWhere },
|
||||
include: {
|
||||
item: true,
|
||||
assignmentLines: {
|
||||
where: { returnedAt: null },
|
||||
include: { assignment: true },
|
||||
include: { assignment: { include: { person: true } } },
|
||||
orderBy: { assignedAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
@@ -182,6 +195,9 @@ export const AssetService = {
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<Asset> => {
|
||||
return prisma.asset.delete({ where: { id } })
|
||||
return prisma.asset.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
+14
-2
@@ -1,9 +1,11 @@
|
||||
import type {
|
||||
Asset as PrismaAsset,
|
||||
AssetStatus as PrismaAssetStatus,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client"
|
||||
|
||||
import type { Assignment } from "./assignment"
|
||||
import type { Person } from "./person"
|
||||
|
||||
export type Asset = PrismaAsset
|
||||
|
||||
@@ -11,13 +13,23 @@ export type ItemStatus = PrismaAssetStatus
|
||||
|
||||
export type UpdateAssetStatus = PrismaAssetStatus
|
||||
|
||||
export type AssetWithAssignment = Asset & {
|
||||
assignment: Assignment | null
|
||||
export type AssetWithAssignment = Prisma.AssetGetPayload<{
|
||||
include: {
|
||||
item: true
|
||||
}
|
||||
}> & {
|
||||
assignment: (Assignment & { person: Person | null }) | null
|
||||
}
|
||||
|
||||
export type AssetWithItemAndCategory = {
|
||||
id: 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
|
||||
status: ItemStatus
|
||||
item: {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
type AssetStatus,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client"
|
||||
import { type AssetStatus, Prisma } from "@/generated/prisma/client"
|
||||
import prisma from "@/lib/prisma"
|
||||
import type {
|
||||
CreateAssetFormType,
|
||||
UpdateAssetFormType,
|
||||
} from "@/schemas/asset.schema"
|
||||
import type { CreateAssetData, UpdateAssetData } from "@/schemas/asset.schema"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { AssignmentService } from "@/services/assignment.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
@@ -15,11 +9,11 @@ import type { Assignment } from "@/types"
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
type CreateAssetUseCaseInput = CreateAssetFormType & {
|
||||
type CreateAssetUseCaseInput = CreateAssetData & {
|
||||
actorId: string
|
||||
}
|
||||
|
||||
type UpdateAssetUseCaseInput = UpdateAssetFormType & {
|
||||
type UpdateAssetUseCaseInput = UpdateAssetData & {
|
||||
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 {
|
||||
constructor(readonly errors: FieldErrors) {
|
||||
super("Asset transition failed")
|
||||
@@ -104,12 +104,23 @@ export async function createAssetUseCase(
|
||||
actorId,
|
||||
itemId,
|
||||
serialNumber,
|
||||
assetTag,
|
||||
manufacturer,
|
||||
model,
|
||||
purchaseDate,
|
||||
purchasePrice,
|
||||
warrantyEndsAt,
|
||||
deliveryNote,
|
||||
status,
|
||||
notes,
|
||||
personId,
|
||||
} = input
|
||||
|
||||
const assignedPersonError = validateAssignedPerson(status, personId)
|
||||
if (assignedPersonError) {
|
||||
return createAssetError(assignedPersonError)
|
||||
}
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const item = await ItemService.findByIdWithCategory(itemId, tx)
|
||||
@@ -133,6 +144,12 @@ export async function createAssetUseCase(
|
||||
{
|
||||
item: { connect: { id: itemId } },
|
||||
serialNumber,
|
||||
assetTag,
|
||||
manufacturer,
|
||||
model,
|
||||
purchaseDate,
|
||||
purchasePrice,
|
||||
warrantyEndsAt,
|
||||
deliveryNote,
|
||||
status,
|
||||
notes,
|
||||
@@ -200,12 +217,23 @@ export async function updateAssetUseCase(
|
||||
id,
|
||||
itemId,
|
||||
serialNumber,
|
||||
assetTag,
|
||||
manufacturer,
|
||||
model,
|
||||
purchaseDate,
|
||||
purchasePrice,
|
||||
warrantyEndsAt,
|
||||
deliveryNote,
|
||||
status,
|
||||
notes,
|
||||
personId,
|
||||
} = input
|
||||
|
||||
const assignedPersonError = validateAssignedPerson(status, personId)
|
||||
if (assignedPersonError) {
|
||||
return updateAssetError(assignedPersonError)
|
||||
}
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const item = await ItemService.findByIdWithCategory(itemId, tx)
|
||||
@@ -249,6 +277,12 @@ export async function updateAssetUseCase(
|
||||
{
|
||||
item: { connect: { id: itemId } },
|
||||
serialNumber,
|
||||
assetTag,
|
||||
manufacturer,
|
||||
model,
|
||||
purchaseDate,
|
||||
purchasePrice,
|
||||
warrantyEndsAt,
|
||||
deliveryNote,
|
||||
status,
|
||||
notes,
|
||||
|
||||
@@ -14,16 +14,19 @@ import {
|
||||
let prisma: PrismaClient
|
||||
let createAssetUseCase: typeof import("@/use-cases/asset.use-cases").createAssetUseCase
|
||||
let updateAssetUseCase: typeof import("@/use-cases/asset.use-cases").updateAssetUseCase
|
||||
let AssetService: typeof import("@/services/asset.service").AssetService
|
||||
|
||||
beforeAll(async () => {
|
||||
await startIntegrationTestDatabase()
|
||||
|
||||
const prismaModule = await import("@/lib/prisma")
|
||||
const assetUseCases = await import("@/use-cases/asset.use-cases")
|
||||
const assetService = await import("@/services/asset.service")
|
||||
|
||||
prisma = prismaModule.prisma
|
||||
createAssetUseCase = assetUseCases.createAssetUseCase
|
||||
updateAssetUseCase = assetUseCases.updateAssetUseCase
|
||||
AssetService = assetService.AssetService
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -74,8 +77,110 @@ describe("asset use-cases", () => {
|
||||
type: "RECEIPT",
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
|
||||
expect(movements[0].assetLines[0]).toMatchObject({ assetId: result.assetId })
|
||||
expect(movements[0].stockLines[0]).toMatchObject({
|
||||
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 () => {
|
||||
@@ -128,8 +233,31 @@ describe("asset use-cases", () => {
|
||||
assignmentId: assignment.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -1 })
|
||||
expect(movements[0].assetLines[0]).toMatchObject({ assetId: result.assetId })
|
||||
expect(movements[0].stockLines[0]).toMatchObject({
|
||||
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 () => {
|
||||
@@ -175,7 +303,9 @@ describe("asset use-cases", () => {
|
||||
expect(activeAssignment).toMatchObject({
|
||||
personId: person.id,
|
||||
})
|
||||
expect(activeAssignment.assetLines[0]).toMatchObject({ assetId: created.assetId })
|
||||
expect(activeAssignment.assetLines[0]).toMatchObject({
|
||||
assetId: created.assetId,
|
||||
})
|
||||
|
||||
await expect(
|
||||
updateAssetUseCase({
|
||||
@@ -217,14 +347,57 @@ describe("asset use-cases", () => {
|
||||
assignmentId: activeAssignment.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -1 })
|
||||
expect(movements[1].assetLines[0]).toMatchObject({ assetId: created.assetId })
|
||||
expect(movements[1].stockLines[0]).toMatchObject({
|
||||
itemId: item.id,
|
||||
stockDelta: -1,
|
||||
})
|
||||
expect(movements[1].assetLines[0]).toMatchObject({
|
||||
assetId: created.assetId,
|
||||
})
|
||||
expect(movements[2]).toMatchObject({
|
||||
assignmentId: activeAssignment.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[2].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
|
||||
expect(movements[2].assetLines[0]).toMatchObject({ assetId: created.assetId })
|
||||
expect(movements[2].stockLines[0]).toMatchObject({
|
||||
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 () => {
|
||||
@@ -286,7 +459,12 @@ describe("asset use-cases", () => {
|
||||
assignmentId: activeAssignment.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
|
||||
expect(movements[1].assetLines[0]).toMatchObject({ assetId: created.assetId })
|
||||
expect(movements[1].stockLines[0]).toMatchObject({
|
||||
itemId: item.id,
|
||||
stockDelta: 1,
|
||||
})
|
||||
expect(movements[1].assetLines[0]).toMatchObject({
|
||||
assetId: created.assetId,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ const schemaCopy = {
|
||||
statusRequired: "El estado es obligatorio",
|
||||
invalidCreateStatus: "El estado inicial no es válido",
|
||||
invalidUpdateStatus: "El estado no es válido",
|
||||
personRequired: "La persona es obligatoria",
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const result = buildCreateAssetSchema(schemaCopy).safeParse({
|
||||
itemId: "item-1",
|
||||
@@ -84,4 +144,63 @@ describe("asset schema localization", () => {
|
||||
|
||||
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",
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user