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 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>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+66
-20
@@ -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({
|
||||||
|
status: z.enum(CREATE_ASSET_STATUSES, {
|
||||||
error: (issue) =>
|
error: (issue) =>
|
||||||
issue.input === undefined || issue.input === ""
|
issue.input === undefined || issue.input === ""
|
||||||
? copy.statusRequired
|
? copy.statusRequired
|
||||||
: copy.invalidCreateStatus,
|
: 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(
|
||||||
|
buildAssetBaseSchema(copy).extend({
|
||||||
id: z.string().min(1, {
|
id: z.string().min(1, {
|
||||||
error: copy.idRequired,
|
error: copy.idRequired,
|
||||||
}),
|
}),
|
||||||
status: z.enum(updateAssetStatuses, {
|
status: z.enum(UPDATE_ASSET_STATUSES, {
|
||||||
error: (issue) =>
|
error: (issue) =>
|
||||||
issue.input === undefined || issue.input === ""
|
issue.input === undefined || issue.input === ""
|
||||||
? copy.statusRequired
|
? copy.statusRequired
|
||||||
: copy.invalidUpdateStatus,
|
: copy.invalidUpdateStatus,
|
||||||
}),
|
}),
|
||||||
})
|
}),
|
||||||
|
copy,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user