Auto-fill AssetItem.warrantyEndDate from AssetModel.warrantyMonths
CI / test (push) Failing after 8s
CI / test (push) Failing after 8s
AssetModel gains an optional warrantyMonths (standard vendor warranty length). createAssetItem() now defaults warrantyEndDate to purchaseDate + warrantyMonths (month-end clamped, e.g. Dec 31 + 2mo -> Feb 28, not Mar 3) whenever the caller omits warrantyEndDate -- still overridable per item for batches with extended/different warranty terms. /asset-models UI gets an input for the new field; /assets needs no change since it already omits warrantyEndDate from the payload when left blank. Hand-written migration + manual smoke test (tsx script, not Vitest), same deviation as the other 2026-08-2x work in this branch -- see CLAUDE.md. tsc/lint clean.
This commit is contained in:
@@ -27,8 +27,14 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { categoryId, manufacturer, name, specs } = body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs });
|
||||
const { categoryId, manufacturer, name, specs, warrantyMonths } = body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetModelInput({
|
||||
categoryId,
|
||||
manufacturer,
|
||||
name,
|
||||
specs,
|
||||
warrantyMonths,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AssetModelRecord {
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
specs: Record<string, unknown>;
|
||||
warranty_months: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -64,6 +65,7 @@ export default function AssetModelsPage() {
|
||||
const [manufacturer, setManufacturer] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
|
||||
const [warrantyMonths, setWarrantyMonths] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
@@ -83,11 +85,24 @@ export default function AssetModelsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedWarranty = warrantyMonths.trim();
|
||||
if (trimmedWarranty !== "" && Number.isNaN(Number(trimmedWarranty))) {
|
||||
setSubmitError("Warranty (months) must be a number");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/asset-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ categoryId, manufacturer, name, specs }),
|
||||
body: JSON.stringify({
|
||||
categoryId,
|
||||
manufacturer,
|
||||
name,
|
||||
specs,
|
||||
...(trimmedWarranty !== "" ? { warrantyMonths: Number(trimmedWarranty) } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
let body: unknown;
|
||||
@@ -113,6 +128,7 @@ export default function AssetModelsPage() {
|
||||
setManufacturer("");
|
||||
setName("");
|
||||
setSpecsText(DEFAULT_SPECS_TEXT);
|
||||
setWarrantyMonths("");
|
||||
refetch();
|
||||
} catch {
|
||||
setSubmitError("Request failed");
|
||||
@@ -139,6 +155,7 @@ export default function AssetModelsPage() {
|
||||
<TableHead>Manufacturer</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Specs</TableHead>
|
||||
<TableHead>Warranty (months)</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -150,6 +167,7 @@ export default function AssetModelsPage() {
|
||||
<TableCell>{assetModel.manufacturer}</TableCell>
|
||||
<TableCell>{assetModel.name}</TableCell>
|
||||
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
|
||||
<TableCell>{assetModel.warranty_months ?? "-"}</TableCell>
|
||||
<TableCell>{assetModel.created_at}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -208,6 +226,23 @@ export default function AssetModelsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="warrantyMonths">Standard Warranty (months, optional)</Label>
|
||||
<Input
|
||||
id="warrantyMonths"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={warrantyMonths}
|
||||
onChange={(event) => setWarrantyMonths(event.target.value)}
|
||||
placeholder="e.g. 36"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used as a default when creating an Asset Item with this model -- warranty end date
|
||||
is auto-filled from purchase date if left blank there, still editable per item.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submitError ? (
|
||||
<p className="text-sm text-destructive">{submitError}</p>
|
||||
) : null}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AssetModelRecord {
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
specs: Record<string, unknown>;
|
||||
warranty_months: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +18,7 @@ export interface CreateAssetModelInput {
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
specs: Record<string, unknown>;
|
||||
warrantyMonths?: number | null;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -57,6 +59,14 @@ const createAssetModelSchema = z.object({
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
// Optional standard warranty length -- see schema.prisma's
|
||||
// AssetModel.warrantyMonths comment for what this drives.
|
||||
warrantyMonths: z
|
||||
.number({ invalid_type_error: "warrantyMonths must be a positive integer" })
|
||||
.int("warrantyMonths must be a positive integer")
|
||||
.positive("warrantyMonths must be a positive integer")
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -71,6 +81,7 @@ export function validateCreateAssetModelInput(input: {
|
||||
manufacturer?: unknown;
|
||||
name?: unknown;
|
||||
specs?: unknown;
|
||||
warrantyMonths?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createAssetModelSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -86,6 +97,7 @@ function toRecord(row: {
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
specs: Prisma.JsonValue;
|
||||
warrantyMonths: number | null;
|
||||
createdAt: Date;
|
||||
}): AssetModelRecord {
|
||||
return {
|
||||
@@ -94,6 +106,7 @@ function toRecord(row: {
|
||||
manufacturer: row.manufacturer,
|
||||
name: row.name,
|
||||
specs: (row.specs as Record<string, unknown>) ?? {},
|
||||
warranty_months: row.warrantyMonths,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -201,6 +214,7 @@ export async function createAssetModel(
|
||||
// is safe here since the Zod schema already guarantees JSON-shaped
|
||||
// values.
|
||||
specs: input.specs as Prisma.InputJsonValue,
|
||||
warrantyMonths: input.warrantyMonths ?? null,
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
|
||||
+38
-1
@@ -136,16 +136,53 @@ export type CreateAssetItemResult =
|
||||
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
||||
* same model.
|
||||
*/
|
||||
// Adds `months` calendar months to `date`, clamping to the last day of the
|
||||
// target month rather than letting JS's native Date rollover push into the
|
||||
// month after (e.g. Jan 31 + 1 month must land on Feb 28/29, not Mar
|
||||
// 2/3 -- `date.setMonth()` alone does the latter). UTC throughout: Prisma
|
||||
// @db.Date fields round-trip as UTC-midnight Dates (see dateString()
|
||||
// above), and doing this in UTC avoids the host server's local timezone
|
||||
// shifting the day near midnight.
|
||||
function addMonthsClamped(date: Date, months: number): Date {
|
||||
const targetMonthIndex = date.getUTCMonth() + months;
|
||||
const daysInTargetMonth = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), targetMonthIndex + 1, 0)
|
||||
).getUTCDate();
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
date.getUTCFullYear(),
|
||||
targetMonthIndex,
|
||||
Math.min(date.getUTCDate(), daysInTargetMonth)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAssetItem(
|
||||
input: CreateAssetItemInput
|
||||
): Promise<CreateAssetItemResult> {
|
||||
try {
|
||||
// warrantyEndDate defaults from the model's standard warranty term
|
||||
// (AssetModel.warrantyMonths) when the caller doesn't supply one
|
||||
// explicitly -- see schema.prisma's AssetModel.warrantyMonths comment
|
||||
// for why this stays a suggestion (still overridable per item) rather
|
||||
// than a hard-computed column. A modelId that doesn't exist is left for
|
||||
// the insert below's own P2003 handling, same as before this lookup was
|
||||
// added -- this just also opportunistically uses the model row if it's
|
||||
// there.
|
||||
let warrantyEndDate = input.warrantyEndDate ?? null;
|
||||
if (!warrantyEndDate) {
|
||||
const model = await prisma.assetModel.findUnique({ where: { id: input.modelId } });
|
||||
if (model?.warrantyMonths) {
|
||||
warrantyEndDate = addMonthsClamped(input.purchaseDate, model.warrantyMonths);
|
||||
}
|
||||
}
|
||||
|
||||
const created = await prisma.assetItem.create({
|
||||
data: {
|
||||
serialNumber: input.serialNumber,
|
||||
modelId: input.modelId,
|
||||
purchaseDate: input.purchaseDate,
|
||||
warrantyEndDate: input.warrantyEndDate ?? null,
|
||||
warrantyEndDate,
|
||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user