Restructure components into types + instances
Deploy to LXC / deploy (push) Successful in 29s

Components are now split into two concepts:
- Component (type definition): title, componentType, brand, partNumber,
  specs, notes, default condition/firmware/location
- Component Instance (individual physical unit): serialNumber, condition,
  firmwareVersion, notes, currentDeviceId, locationId

Key changes:
- New component_instances table with per-unit tracking
- Component list shows cards with total/installed/available counts
- Component detail page shows all instances with inline edit
- Add instances: bulk (quantity) or one at a time
- Each instance has install/remove/edit/delete actions
- Installation log now references instances, not components
- Device detail shows installed instances with instance numbers
- Dashboard and sidebar counts use instance totals
- Location pages show instances, not component types
- Labels show component type info (serial is per-instance)

IMPORTANT: Run db:push on the server after deploy to create the new
tables and columns. Existing component data will need manual migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:41:06 +07:00
parent bb8a96d281
commit 5c4595ed16
26 changed files with 553 additions and 373 deletions
+31 -9
View File
@@ -123,7 +123,7 @@ export const deviceDocuments = pgTable(
(table) => [index('device_documents_device_idx').on(table.deviceId)]
);
// ─── Components ─────────────────────────────────────────────────────
// ─── Components (type definitions) ──────────────────────────────────
export const components = pgTable(
'components',
@@ -133,10 +133,32 @@ export const components = pgTable(
componentType: text('component_type').notNull(),
brand: text('brand'),
partNumber: text('part_number'),
specs: text('specs'),
notes: text('notes'),
defaultCondition: text('default_condition').notNull().default('Working'),
defaultFirmwareVersion: text('default_firmware_version'),
defaultLocationId: uuid('default_location_id').references(() => locations.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('components_type_idx').on(table.componentType)
]
);
// ─── Component Instances (individual physical units) ────────────────
export const componentInstances = pgTable(
'component_instances',
{
id: uuid('id').defaultRandom().primaryKey(),
componentId: uuid('component_id')
.notNull()
.references(() => components.id, { onDelete: 'cascade' }),
instanceNumber: integer('instance_number').notNull().default(1),
serialNumber: text('serial_number'),
condition: text('condition').notNull().default('Working'),
firmwareVersion: text('firmware_version'),
specs: text('specs'),
notes: text('notes'),
currentDeviceId: uuid('current_device_id').references(() => devices.id, {
onDelete: 'set null'
@@ -146,10 +168,10 @@ export const components = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('components_type_idx').on(table.componentType),
index('components_device_idx').on(table.currentDeviceId),
index('components_location_idx').on(table.locationId),
check('components_condition_check', sql`${table.condition} IN ('Working', 'Faulty', 'Unknown', 'Refurbished')`)
index('instances_component_idx').on(table.componentId),
index('instances_device_idx').on(table.currentDeviceId),
index('instances_location_idx').on(table.locationId),
check('instances_condition_check', sql`${table.condition} IN ('Working', 'Faulty', 'Unknown', 'Refurbished')`)
]
);
@@ -194,9 +216,9 @@ export const installationLog = pgTable(
'installation_log',
{
id: uuid('id').defaultRandom().primaryKey(),
componentId: uuid('component_id')
instanceId: uuid('instance_id')
.notNull()
.references(() => components.id, { onDelete: 'cascade' }),
.references(() => componentInstances.id, { onDelete: 'cascade' }),
deviceId: uuid('device_id')
.notNull()
.references(() => devices.id, { onDelete: 'cascade' }),
@@ -206,7 +228,7 @@ export const installationLog = pgTable(
performedAt: timestamp('performed_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('install_log_component_idx').on(table.componentId),
index('install_log_instance_idx').on(table.instanceId),
index('install_log_device_idx').on(table.deviceId),
index('install_log_date_idx').on(table.performedAt),
check('install_log_action_check', sql`${table.action} IN ('installed', 'removed', 'swapped')`)
+2 -2
View File
@@ -1,7 +1,7 @@
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { devices, components } from '$lib/server/db/schema.js';
import { devices, componentInstances } from '$lib/server/db/schema.js';
import { count, eq, or, and } from 'drizzle-orm';
export const load: LayoutServerLoad = async ({ locals }) => {
@@ -10,7 +10,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
}
const [deviceCount] = await db.select({ value: count() }).from(devices).where(eq(devices.disabled, false));
const [componentCount] = await db.select({ value: count() }).from(components);
const [componentCount] = await db.select({ value: count() }).from(componentInstances);
const [repairCount] = await db
.select({ value: count() })
.from(devices)
+6 -4
View File
@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices, components, installationLog } from '$lib/server/db/schema.js';
import { devices, components, componentInstances, installationLog } from '$lib/server/db/schema.js';
import { count, eq, or, and, desc, sql } from 'drizzle-orm';
export const load: PageServerLoad = async () => {
@@ -16,7 +16,7 @@ export const load: PageServerLoad = async () => {
.select({ value: count() })
.from(devices)
.where(and(active, eq(devices.category, 'Audio Equipment')));
const [totalComponents] = await db.select({ value: count() }).from(components);
const [totalComponents] = await db.select({ value: count() }).from(componentInstances);
// Condition breakdown
const conditionBreakdown = await db
@@ -33,13 +33,15 @@ export const load: PageServerLoad = async () => {
.select({
action: installationLog.action,
performedAt: installationLog.performedAt,
componentId: installationLog.componentId,
instanceId: installationLog.instanceId,
instanceNumber: componentInstances.instanceNumber,
deviceId: installationLog.deviceId,
componentTitle: components.title,
deviceTitle: devices.title
})
.from(installationLog)
.innerJoin(components, eq(installationLog.componentId, components.id))
.innerJoin(componentInstances, eq(installationLog.instanceId, componentInstances.id))
.innerJoin(components, eq(componentInstances.componentId, components.id))
.innerJoin(devices, eq(installationLog.deviceId, devices.id))
.orderBy(desc(installationLog.performedAt))
.limit(10);
+1 -1
View File
@@ -71,7 +71,7 @@
</span>
<div class="flex-1">
<p class="text-sm text-gray-700 dark:text-gray-300">
<a href="/components/{entry.componentId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{entry.componentTitle}</a>
<span class="font-medium">{entry.componentTitle} <span class="text-gray-400">#{entry.instanceNumber}</span></span>
{entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'}
<a href="/devices/{entry.deviceId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{entry.deviceTitle}</a>
</p>
+32 -19
View File
@@ -1,29 +1,22 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations } from '$lib/server/db/schema.js';
import { eq, ilike, or, and, isNull, isNotNull, count, desc } from 'drizzle-orm';
import { components, componentInstances, devices } from '$lib/server/db/schema.js';
import { eq, ilike, or, and, isNull, isNotNull, count, desc, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url }) => {
const type = url.searchParams.get('type');
const condition = url.searchParams.get('condition');
const status = url.searchParams.get('status'); // 'installed' | 'storage'
const search = url.searchParams.get('q');
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1));
const pageSize = 24;
const conditions = [];
if (type) conditions.push(eq(components.componentType, type));
if (condition) conditions.push(eq(components.condition, condition));
if (status === 'installed') conditions.push(isNotNull(components.currentDeviceId));
if (status === 'storage') conditions.push(isNull(components.currentDeviceId));
if (search) {
conditions.push(
or(
ilike(components.title, `%${search}%`),
ilike(components.brand, `%${search}%`),
ilike(components.partNumber, `%${search}%`),
ilike(components.serialNumber, `%${search}%`)
ilike(components.partNumber, `%${search}%`)
)!
);
}
@@ -39,24 +32,44 @@ export const load: PageServerLoad = async ({ url }) => {
componentType: components.componentType,
brand: components.brand,
partNumber: components.partNumber,
condition: components.condition,
currentDeviceId: components.currentDeviceId,
deviceTitle: devices.title,
locationName: locations.name
specs: components.specs
})
.from(components)
.leftJoin(devices, eq(components.currentDeviceId, devices.id))
.leftJoin(locations, eq(components.locationId, locations.id))
.where(where)
.orderBy(desc(components.updatedAt))
.orderBy(components.title)
.limit(pageSize)
.offset((page - 1) * pageSize);
// Get instance counts per component
const componentIds = componentList.map((c) => c.id);
let instanceCounts: Record<string, { total: number; installed: number }> = {};
if (componentIds.length > 0) {
const counts = await db
.select({
componentId: componentInstances.componentId,
total: count(),
installed: sql<number>`count(${componentInstances.currentDeviceId})`
})
.from(componentInstances)
.where(sql`${componentInstances.componentId} IN ${componentIds}`)
.groupBy(componentInstances.componentId);
for (const c of counts) {
instanceCounts[c.componentId] = { total: c.total, installed: Number(c.installed) };
}
}
return {
components: componentList,
components: componentList.map((c) => ({
...c,
total: instanceCounts[c.id]?.total ?? 0,
installed: instanceCounts[c.id]?.installed ?? 0,
available: (instanceCounts[c.id]?.total ?? 0) - (instanceCounts[c.id]?.installed ?? 0)
})),
total: totalResult?.value ?? 0,
page,
pageSize,
filters: { type, condition, status, search }
filters: { type, search }
};
};
+29 -62
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { COMPONENT_TYPES, COMPONENT_CONDITIONS } from '$lib/constants.js';
import { COMPONENT_TYPES } from '$lib/constants.js';
let { data } = $props();
@@ -48,22 +48,9 @@
<option value={t} selected={data.filters.type === t}>{t}</option>
{/each}
</select>
<select onchange={(e) => applyFilter('condition', (e.target as HTMLSelectElement).value || null)}
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Conditions</option>
{#each COMPONENT_CONDITIONS as c}
<option value={c} selected={data.filters.condition === c}>{c}</option>
{/each}
</select>
<select onchange={(e) => applyFilter('status', (e.target as HTMLSelectElement).value || null)}
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All</option>
<option value="installed" selected={data.filters.status === 'installed'}>Installed</option>
<option value="storage" selected={data.filters.status === 'storage'}>In Storage</option>
</select>
</div>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">{data.total} component{data.total !== 1 ? 's' : ''}</p>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">{data.total} component type{data.total !== 1 ? 's' : ''}</p>
{#if data.components.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
@@ -71,53 +58,33 @@
<a href="/components/new" class="mt-2 inline-block text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">Add your first component</a>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Component</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Type</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Condition</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Location</th>
</tr>
</thead>
<tbody>
{#each data.components as comp}
<tr class="border-b border-gray-100 last:border-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/30">
<td class="px-4 py-3">
<a href="/components/{comp.id}" class="font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{comp.title}
</a>
{#if comp.brand}
<span class="ml-1 text-gray-400 dark:text-gray-500">{comp.brand}</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{comp.componentType}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{comp.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{comp.condition === 'Faulty' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
{comp.condition === 'Unknown' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : ''}
{comp.condition === 'Refurbished' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
">
{comp.condition}
</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{#if comp.deviceTitle}
<a href="/devices/{comp.currentDeviceId}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">
{comp.deviceTitle}
</a>
{:else if comp.locationName}
{comp.locationName}
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.components as comp}
<a href="/components/{comp.id}"
class="group rounded-lg border border-gray-200 bg-white p-4 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div class="mb-2 flex items-start justify-between">
<h3 class="font-medium text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">{comp.title}</h3>
<span class="flex-shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{comp.componentType}
</span>
</div>
{#if comp.brand || comp.partNumber}
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400">
{[comp.brand, comp.partNumber ? `P/N: ${comp.partNumber}` : ''].filter(Boolean).join(' · ')}
</p>
{/if}
<!-- Stock info -->
<div class="flex items-center gap-3 text-xs">
<span class="font-medium text-gray-700 dark:text-gray-300">{comp.total} total</span>
{#if comp.installed > 0}
<span class="text-blue-600 dark:text-blue-400">{comp.installed} installed</span>
{/if}
<span class="{comp.available > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
{comp.available} available
</span>
</div>
</a>
{/each}
</div>
{/if}
@@ -1,46 +1,37 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations, installationLog, componentImages, componentDocuments } from '$lib/server/db/schema.js';
import { components, componentInstances, devices, locations, installationLog, componentImages, componentDocuments } from '$lib/server/db/schema.js';
import { eq, desc } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { saveDocument, deleteFile } from '$lib/server/uploads.js';
export const load: PageServerLoad = async ({ params }) => {
const [component] = await db
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
brand: components.brand,
partNumber: components.partNumber,
serialNumber: components.serialNumber,
condition: components.condition,
firmwareVersion: components.firmwareVersion,
specs: components.specs,
notes: components.notes,
currentDeviceId: components.currentDeviceId,
deviceTitle: devices.title,
locationId: components.locationId,
locationName: locations.name,
locationParentId: locations.parentId,
createdAt: components.createdAt,
updatedAt: components.updatedAt
})
.select()
.from(components)
.leftJoin(devices, eq(components.currentDeviceId, devices.id))
.leftJoin(locations, eq(components.locationId, locations.id))
.where(eq(components.id, params.id));
if (!component) error(404, 'Component not found');
let parentLocationName: string | null = null;
if (component.locationParentId) {
const [parent] = await db
.select({ name: locations.name })
.from(locations)
.where(eq(locations.id, component.locationParentId));
parentLocationName = parent?.name ?? null;
}
// All instances with their device/location info
const instances = await db
.select({
id: componentInstances.id,
instanceNumber: componentInstances.instanceNumber,
serialNumber: componentInstances.serialNumber,
condition: componentInstances.condition,
firmwareVersion: componentInstances.firmwareVersion,
notes: componentInstances.notes,
currentDeviceId: componentInstances.currentDeviceId,
deviceTitle: devices.title,
locationId: componentInstances.locationId,
locationName: locations.name
})
.from(componentInstances)
.leftJoin(devices, eq(componentInstances.currentDeviceId, devices.id))
.leftJoin(locations, eq(componentInstances.locationId, locations.id))
.where(eq(componentInstances.componentId, params.id))
.orderBy(componentInstances.instanceNumber);
const images = await db
.select()
@@ -52,6 +43,7 @@ export const load: PageServerLoad = async ({ params }) => {
.from(componentDocuments)
.where(eq(componentDocuments.componentId, params.id));
// Installation history across all instances
const history = await db
.select({
id: installationLog.id,
@@ -59,18 +51,86 @@ export const load: PageServerLoad = async ({ params }) => {
performedAt: installationLog.performedAt,
performedBy: installationLog.performedBy,
notes: installationLog.notes,
instanceId: installationLog.instanceId,
instanceNumber: componentInstances.instanceNumber,
deviceId: installationLog.deviceId,
deviceTitle: devices.title
})
.from(installationLog)
.innerJoin(componentInstances, eq(installationLog.instanceId, componentInstances.id))
.innerJoin(devices, eq(installationLog.deviceId, devices.id))
.where(eq(installationLog.componentId, params.id))
.orderBy(desc(installationLog.performedAt));
.where(eq(componentInstances.componentId, params.id))
.orderBy(desc(installationLog.performedAt))
.limit(20);
return { component, parentLocationName, images, documents, history };
const locationList = await db
.select({ id: locations.id, name: locations.name, parentId: locations.parentId })
.from(locations)
.orderBy(locations.name);
return { component, instances, images, documents, history, locations: locationList };
};
export const actions: Actions = {
addInstances: async ({ request, params }) => {
const formData = await request.formData();
const quantity = Number(formData.get('quantity') ?? 1);
if (quantity < 1 || quantity > 100) return fail(400, { error: 'Quantity must be 1-100' });
const [component] = await db.select().from(components).where(eq(components.id, params.id));
if (!component) return fail(400, { error: 'Component not found' });
// Get the next instance number
const existing = await db
.select({ instanceNumber: componentInstances.instanceNumber })
.from(componentInstances)
.where(eq(componentInstances.componentId, params.id))
.orderBy(desc(componentInstances.instanceNumber))
.limit(1);
const startNum = (existing[0]?.instanceNumber ?? 0) + 1;
const instances = Array.from({ length: quantity }, (_, i) => ({
componentId: params.id,
instanceNumber: startNum + i,
condition: component.defaultCondition,
firmwareVersion: component.defaultFirmwareVersion,
locationId: component.defaultLocationId
}));
await db.insert(componentInstances).values(instances);
return { instancesAdded: true };
},
editInstance: async ({ request }) => {
const formData = await request.formData();
const instanceId = formData.get('instanceId') as string;
const serialNumber = (formData.get('serialNumber') as string)?.trim();
const condition = formData.get('condition') as string;
const firmwareVersion = (formData.get('firmwareVersion') as string)?.trim();
const notes = (formData.get('notes') as string)?.trim();
await db
.update(componentInstances)
.set({
serialNumber: serialNumber || null,
condition: condition || 'Working',
firmwareVersion: firmwareVersion || null,
notes: notes || null,
updatedAt: new Date()
})
.where(eq(componentInstances.id, instanceId));
return { instanceEdited: true };
},
deleteInstance: async ({ request }) => {
const formData = await request.formData();
const instanceId = formData.get('instanceId') as string;
await db.delete(componentInstances).where(eq(componentInstances.id, instanceId));
return { instanceDeleted: true };
},
uploadDocument: async ({ request, params }) => {
const formData = await request.formData();
const file = formData.get('document') as File;
+161 -51
View File
@@ -1,10 +1,18 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { COMPONENT_CONDITIONS } from '$lib/constants.js';
import { formatDate, timeAgo } from '$lib/utils/date.js';
let { data } = $props();
let { data, form } = $props();
const c = $derived(data.component);
let editingInstanceId = $state<string | null>(null);
let showAddInstances = $state(false);
let showDocForm = $state(false);
const totalCount = $derived(data.instances.length);
const installedCount = $derived(data.instances.filter((i) => i.currentDeviceId).length);
const availableCount = $derived(totalCount - installedCount);
</script>
<svelte:head>
@@ -16,19 +24,24 @@
<div>
<div class="mb-1 flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{c.title}</h1>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{c.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{c.condition === 'Faulty' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
{c.condition === 'Unknown' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : ''}
{c.condition === 'Refurbished' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
">
{c.condition}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{c.componentType}
</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
{c.componentType}
{#if c.brand}&middot; {c.brand}{/if}
{#if c.brand}{c.brand}{/if}
{#if c.partNumber} · P/N: {c.partNumber}{/if}
</p>
<!-- Stock summary -->
<div class="mt-2 flex items-center gap-3 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{totalCount} total</span>
{#if installedCount > 0}
<span class="text-blue-600 dark:text-blue-400">{installedCount} installed</span>
{/if}
<span class="{availableCount > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
{availableCount} available
</span>
</div>
</div>
<div class="flex gap-2">
<a href="/components/{c.id}/edit"
@@ -39,38 +52,144 @@
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
Label
</a>
<button onclick={() => window.open(`/print/component/${c.id}`, '_blank', 'width=600,height=400')}
<button type="button" onclick={() => window.open(`/print/component/${c.id}`, '_blank', 'width=600,height=400')}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
Print
</button>
{#if c.currentDeviceId}
<a href="/installations/new?componentId={c.id}&action=removed"
class="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Remove from Device
</a>
{:else}
<a href="/installations/new?componentId={c.id}"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Install into Device
</a>
{/if}
</div>
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
<div class="grid gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<!-- Current Location -->
<!-- Instances -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Current Location</h2>
{#if c.currentDeviceId}
<p class="text-sm text-gray-700 dark:text-gray-300">
Installed in
<a href="/devices/{c.currentDeviceId}" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">{c.deviceTitle}</a>
</p>
{:else if c.locationName}
<p class="text-sm text-gray-700 dark:text-gray-300">In storage at <a href="/locations/{c.locationId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{#if data.parentLocationName}{data.parentLocationName} &rsaquo; {/if}{c.locationName}</a></p>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Instances</h2>
<button type="button" onclick={() => (showAddInstances = !showAddInstances)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showAddInstances ? 'Cancel' : 'Add More'}
</button>
</div>
{#if showAddInstances}
<form method="POST" action="?/addInstances" use:enhance class="mb-4 flex items-end gap-2">
<div>
<label for="quantity" class="mb-1 block text-xs text-gray-500 dark:text-gray-400">Quantity</label>
<input type="number" id="quantity" name="quantity" value="1" min="1" max="100"
class="w-20 rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Add</button>
</form>
{/if}
{#if data.instances.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No instances. Add some above.</p>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">In storage (no location set)</p>
<div class="space-y-2">
{#each data.instances as inst}
{#if editingInstanceId === inst.id}
<form method="POST" action="?/editInstance" use:enhance={() => {
return async ({ update, result }) => {
await update();
if (result.type === 'success') editingInstanceId = null;
};
}} class="rounded-md border border-blue-200 bg-blue-50/50 p-3 dark:border-blue-800 dark:bg-blue-900/10">
<input type="hidden" name="instanceId" value={inst.id} />
<div class="mb-2 grid gap-2 sm:grid-cols-2">
<div>
<span class="mb-1 block text-xs text-gray-500">Serial Number</span>
<input type="text" name="serialNumber" value={inst.serialNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<span class="mb-1 block text-xs text-gray-500">Condition</span>
<select name="condition"
class="w-full rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_CONDITIONS as cond}
<option value={cond} selected={cond === inst.condition}>{cond}</option>
{/each}
</select>
</div>
</div>
<div class="mb-2 grid gap-2 sm:grid-cols-2">
<div>
<span class="mb-1 block text-xs text-gray-500">Firmware</span>
<input type="text" name="firmwareVersion" value={inst.firmwareVersion ?? ''}
class="w-full rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<span class="mb-1 block text-xs text-gray-500">Notes</span>
<input type="text" name="notes" value={inst.notes ?? ''}
class="w-full rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
<div class="flex gap-2">
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700">Save</button>
<button type="button" onclick={() => (editingInstanceId = null)} class="text-sm text-gray-500 dark:text-gray-400">Cancel</button>
</div>
</form>
{:else}
<div class="group flex items-center gap-3 rounded-md border border-gray-100 p-3 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/30">
<span class="w-6 text-right text-xs font-medium text-gray-400 dark:text-gray-500">#{inst.instanceNumber}</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{inst.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{inst.condition === 'Faulty' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
{inst.condition === 'Unknown' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : ''}
{inst.condition === 'Refurbished' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
">{inst.condition}</span>
{#if inst.serialNumber}
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">S/N: {inst.serialNumber}</span>
{/if}
{#if inst.firmwareVersion}
<span class="text-xs text-gray-400 dark:text-gray-500">FW: {inst.firmwareVersion}</span>
{/if}
</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{#if inst.currentDeviceId}
Installed in <a href="/devices/{inst.currentDeviceId}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{inst.deviceTitle}</a>
{:else if inst.locationName}
{inst.locationName}
{:else}
In storage
{/if}
{#if inst.notes}
· {inst.notes}
{/if}
</div>
</div>
<div class="flex items-center gap-1">
{#if !inst.currentDeviceId}
<a href="/installations/new?instanceId={inst.id}"
class="hidden rounded px-2 py-0.5 text-xs text-blue-600 hover:bg-blue-50 group-hover:block dark:text-blue-400 dark:hover:bg-blue-900/20">
Install
</a>
{:else}
<a href="/installations/new?instanceId={inst.id}&action=removed"
class="hidden rounded px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 group-hover:block dark:text-red-400 dark:hover:bg-red-900/20">
Remove
</a>
{/if}
<button type="button" onclick={() => (editingInstanceId = inst.id)}
class="hidden rounded p-1 text-gray-400 hover:text-blue-600 group-hover:block dark:text-gray-500" title="Edit">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
<form method="POST" action="?/deleteInstance" use:enhance>
<input type="hidden" name="instanceId" value={inst.id} />
<button type="submit" class="hidden rounded p-1 text-gray-400 hover:text-red-500 group-hover:block dark:text-gray-500" title="Delete">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</form>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
@@ -90,6 +209,7 @@
<div class="flex-1">
<p class="text-sm text-gray-700 dark:text-gray-300">
<span class="font-medium capitalize">{entry.action}</span>
<span class="text-gray-400">#{entry.instanceNumber}</span>
{entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'}
<a href="/devices/{entry.deviceId}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{entry.deviceTitle}</a>
</p>
@@ -98,7 +218,7 @@
{/if}
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{timeAgo(entry.performedAt)}
{#if entry.performedBy}&middot; {entry.performedBy}{/if}
{#if entry.performedBy}· {entry.performedBy}{/if}
</p>
</div>
</div>
@@ -119,24 +239,18 @@
<dd class="font-mono text-gray-900 dark:text-white">{c.partNumber}</dd>
</div>
{/if}
{#if c.serialNumber}
<div>
<dt class="text-gray-500 dark:text-gray-400">Serial Number</dt>
<dd class="font-mono text-gray-900 dark:text-white">{c.serialNumber}</dd>
</div>
{/if}
{#if c.firmwareVersion}
<div>
<dt class="text-gray-500 dark:text-gray-400">Firmware</dt>
<dd class="text-gray-900 dark:text-white">{c.firmwareVersion}</dd>
</div>
{/if}
{#if c.specs}
<div>
<dt class="text-gray-500 dark:text-gray-400">Specs</dt>
<dd class="whitespace-pre-wrap text-gray-900 dark:text-white">{c.specs}</dd>
</div>
{/if}
{#if c.defaultFirmwareVersion}
<div>
<dt class="text-gray-500 dark:text-gray-400">Default Firmware</dt>
<dd class="text-gray-900 dark:text-white">{c.defaultFirmwareVersion}</dd>
</div>
{/if}
<div>
<dt class="text-gray-500 dark:text-gray-400">Added</dt>
<dd class="text-gray-900 dark:text-white">{formatDate(c.createdAt)}</dd>
@@ -153,7 +267,6 @@
{showDocForm ? 'Cancel' : 'Upload'}
</button>
</div>
{#if showDocForm}
<form method="POST" action="?/uploadDocument" enctype="multipart/form-data" use:enhance class="mb-3 space-y-2">
<input type="file" name="document" required class="text-sm text-gray-600 dark:text-gray-400" />
@@ -162,7 +275,6 @@
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
</form>
{/if}
{#if data.documents.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p>
{:else}
@@ -178,10 +290,8 @@
</a>
<form method="POST" action="?/deleteDocument" use:enhance>
<input type="hidden" name="docId" value={doc.id} />
<button type="submit" class="hidden rounded p-1 text-gray-400 hover:text-red-500 group-hover:block dark:text-gray-500 dark:hover:text-red-400" title="Delete">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<button type="submit" class="hidden rounded p-1 text-gray-400 hover:text-red-500 group-hover:block dark:text-gray-500" title="Delete">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</form>
</div>
@@ -1,6 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations } from '$lib/server/db/schema.js';
import { components, locations } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
@@ -10,12 +10,11 @@ const componentSchema = z.object({
componentType: z.string().min(1),
brand: z.string().optional(),
partNumber: z.string().optional(),
serialNumber: z.string().optional(),
condition: z.enum(['Working', 'Faulty', 'Unknown', 'Refurbished']),
firmwareVersion: z.string().optional(),
specs: z.string().optional(),
notes: z.string().optional(),
locationId: z.string().uuid().optional().or(z.literal(''))
defaultCondition: z.enum(['Working', 'Faulty', 'Unknown', 'Refurbished']),
defaultFirmwareVersion: z.string().optional(),
defaultLocationId: z.string().uuid().optional().or(z.literal(''))
});
export const load: PageServerLoad = async ({ params }) => {
@@ -46,12 +45,11 @@ export const actions: Actions = {
componentType: data.componentType,
brand: data.brand || null,
partNumber: data.partNumber || null,
serialNumber: data.serialNumber || null,
condition: data.condition,
firmwareVersion: data.firmwareVersion || null,
specs: data.specs || null,
notes: data.notes || null,
locationId: data.locationId || null,
defaultCondition: data.defaultCondition,
defaultFirmwareVersion: data.defaultFirmwareVersion || null,
defaultLocationId: data.defaultLocationId || null,
updatedAt: new Date()
})
.where(eq(components.id, params.id));
@@ -20,7 +20,7 @@
<form method="POST" use:enhance class="space-y-6">
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Component Info</h2>
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Component Type</h2>
<div class="mb-4">
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
@@ -28,28 +28,17 @@
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="componentType" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Type *</label>
<select id="componentType" name="componentType" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_TYPES as t}
<option value={t} selected={t === (form?.values?.componentType ?? c.componentType)}>{t}</option>
{/each}
</select>
</div>
<div>
<label for="condition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition *</label>
<select id="condition" name="condition"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_CONDITIONS as cnd}
<option value={cnd} selected={cnd === (form?.values?.condition ?? c.condition)}>{cnd}</option>
{/each}
</select>
</div>
<div class="mb-4">
<label for="componentType" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Type *</label>
<select id="componentType" name="componentType" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_TYPES as t}
<option value={t} selected={t === (form?.values?.componentType ?? c.componentType)}>{t}</option>
{/each}
</select>
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="brand" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Brand</label>
<input type="text" id="brand" name="brand" value={form?.values?.brand ?? c.brand ?? ''}
@@ -60,17 +49,6 @@
<input type="text" id="partNumber" name="partNumber" value={form?.values?.partNumber ?? c.partNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<label for="serialNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Serial Number</label>
<input type="text" id="serialNumber" name="serialNumber" value={form?.values?.serialNumber ?? c.serialNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
<div class="mb-4">
<label for="firmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware Version</label>
<input type="text" id="firmwareVersion" name="firmwareVersion" value={form?.values?.firmwareVersion ?? c.firmwareVersion ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div class="mb-4">
@@ -86,12 +64,29 @@
</div>
</div>
{#if !c.currentDeviceId}
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Storage Location</h2>
<LocationPicker locations={data.locations} value={c.locationId} name="locationId" />
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Defaults for New Instances</h2>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="defaultCondition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition</label>
<select id="defaultCondition" name="defaultCondition"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_CONDITIONS as cond}
<option value={cond} selected={cond === (form?.values?.defaultCondition ?? c.defaultCondition)}>{cond}</option>
{/each}
</select>
</div>
<div>
<label for="defaultFirmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware</label>
<input type="text" id="defaultFirmwareVersion" name="defaultFirmwareVersion" value={form?.values?.defaultFirmwareVersion ?? c.defaultFirmwareVersion ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
{/if}
<div>
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Default Location</span>
<LocationPicker locations={data.locations} value={c.defaultLocationId} name="defaultLocationId" />
</div>
</div>
<div class="flex items-center gap-3">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save Changes</button>
@@ -13,8 +13,7 @@ export const load: PageServerLoad = async ({ params }) => {
title: components.title,
componentType: components.componentType,
brand: components.brand,
partNumber: components.partNumber,
serialNumber: components.serialNumber
partNumber: components.partNumber
})
.from(components)
.where(eq(components.id, params.id));
@@ -35,8 +35,8 @@
{#if data.component.partNumber}
<p class="mt-1 font-mono text-xs text-gray-500 dark:text-gray-400">P/N: {data.component.partNumber}</p>
{/if}
{#if data.component.serialNumber}
<p class="font-mono text-xs text-gray-500 dark:text-gray-400">S/N: {data.component.serialNumber}</p>
{#if data.component.partNumber}
<p class="font-mono text-xs text-gray-500 dark:text-gray-400">P/N: {data.component.partNumber}</p>
{/if}
<p class="mt-2 font-mono text-sm font-medium text-gray-500 dark:text-gray-400">{data.component.id.slice(0, 8)}</p>
</div>
+23 -17
View File
@@ -1,6 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations } from '$lib/server/db/schema.js';
import { components, componentInstances, locations } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
@@ -10,26 +10,20 @@ const componentSchema = z.object({
componentType: z.string().min(1, 'Component type is required'),
brand: z.string().optional(),
partNumber: z.string().optional(),
serialNumber: z.string().optional(),
condition: z.enum(['Working', 'Faulty', 'Unknown', 'Refurbished']),
firmwareVersion: z.string().optional(),
specs: z.string().optional(),
notes: z.string().optional(),
currentDeviceId: z.string().uuid().optional().or(z.literal('')),
locationId: z.string().uuid().optional().or(z.literal(''))
defaultCondition: z.enum(['Working', 'Faulty', 'Unknown', 'Refurbished']),
defaultFirmwareVersion: z.string().optional(),
defaultLocationId: z.string().uuid().optional().or(z.literal('')),
quantity: z.coerce.number().int().min(1).max(100).optional()
});
export const load: PageServerLoad = async () => {
const deviceList = await db
.select({ id: devices.id, title: devices.title })
.from(devices)
.where(eq(devices.disabled, false))
.orderBy(devices.title);
const locationList = await db
.select({ id: locations.id, name: locations.name, parentId: locations.parentId })
.from(locations)
.orderBy(locations.name);
return { devices: deviceList, locations: locationList };
return { locations: locationList };
};
export const actions: Actions = {
@@ -43,6 +37,7 @@ export const actions: Actions = {
}
const data = result.data;
const quantity = data.quantity ?? 1;
const [component] = await db
.insert(components)
@@ -51,16 +46,27 @@ export const actions: Actions = {
componentType: data.componentType,
brand: data.brand || null,
partNumber: data.partNumber || null,
serialNumber: data.serialNumber || null,
condition: data.condition,
firmwareVersion: data.firmwareVersion || null,
specs: data.specs || null,
notes: data.notes || null,
currentDeviceId: data.currentDeviceId || null,
locationId: data.currentDeviceId ? null : data.locationId || null
defaultCondition: data.defaultCondition,
defaultFirmwareVersion: data.defaultFirmwareVersion || null,
defaultLocationId: data.defaultLocationId || null
})
.returning({ id: components.id });
// Create instances
if (component) {
const instances = Array.from({ length: quantity }, (_, i) => ({
componentId: component.id,
instanceNumber: i + 1,
condition: data.defaultCondition,
firmwareVersion: data.defaultFirmwareVersion || null,
locationId: data.defaultLocationId || null
}));
await db.insert(componentInstances).values(instances);
}
redirect(303, `/components/${component!.id}`);
}
};
+25 -33
View File
@@ -19,13 +19,13 @@
<form method="POST" use:enhance class="space-y-6">
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Component Info</h2>
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Component Type</h2>
<div class="mb-4">
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
<input type="text" id="title" name="title" required value={form?.values?.title ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
placeholder="e.g. BlueSCSI v2 #1" />
placeholder="e.g. 4MB 72-pin SIMM" />
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
@@ -39,17 +39,14 @@
</select>
</div>
<div>
<label for="condition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition *</label>
<select id="condition" name="condition"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_CONDITIONS as c}
<option value={c} selected={c === (form?.values?.condition ?? 'Working')}>{c}</option>
{/each}
</select>
<label for="quantity" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Quantity</label>
<input type="number" id="quantity" name="quantity" value={form?.values?.quantity ?? 1} min="1" max="100"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">Creates this many individual instances</p>
</div>
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="brand" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Brand</label>
<input type="text" id="brand" name="brand" value={form?.values?.brand ?? ''}
@@ -60,24 +57,13 @@
<input type="text" id="partNumber" name="partNumber" value={form?.values?.partNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<label for="serialNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Serial Number</label>
<input type="text" id="serialNumber" name="serialNumber" value={form?.values?.serialNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
<div class="mb-4">
<label for="firmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware Version</label>
<input type="text" id="firmwareVersion" name="firmwareVersion" value={form?.values?.firmwareVersion ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div class="mb-4">
<label for="specs" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Specs</label>
<textarea id="specs" name="specs" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g. 32MB 72-pin SIMM">{form?.values?.specs ?? ''}</textarea>
placeholder="e.g. 32MB 72-pin SIMM, 60ns">{form?.values?.specs ?? ''}</textarea>
</div>
<div>
@@ -88,20 +74,26 @@
</div>
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Location</h2>
<div class="mb-4">
<label for="currentDeviceId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Currently Installed In</label>
<select id="currentDeviceId" name="currentDeviceId"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Not installed (in storage)</option>
{#each data.devices as d}
<option value={d.id}>{d.title}</option>
{/each}
</select>
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Defaults for Instances</h2>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="defaultCondition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition</label>
<select id="defaultCondition" name="defaultCondition"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_CONDITIONS as c}
<option value={c} selected={c === (form?.values?.defaultCondition ?? 'Working')}>{c}</option>
{/each}
</select>
</div>
<div>
<label for="defaultFirmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware Version</label>
<input type="text" id="defaultFirmwareVersion" name="defaultFirmwareVersion" value={form?.values?.defaultFirmwareVersion ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage Location</span>
<LocationPicker locations={data.locations} name="locationId" />
<LocationPicker locations={data.locations} name="defaultLocationId" />
</div>
</div>
+15 -8
View File
@@ -6,6 +6,7 @@ import {
deviceImages,
deviceDocuments,
components,
componentInstances,
installationLog,
deviceLog,
deviceChecklists,
@@ -83,16 +84,20 @@ export const load: PageServerLoad = async ({ params }) => {
.from(deviceDocuments)
.where(eq(deviceDocuments.deviceId, params.id));
// Installed components
// Installed component instances
const installedComponents = await db
.select({
id: components.id,
instanceId: componentInstances.id,
instanceNumber: componentInstances.instanceNumber,
serialNumber: componentInstances.serialNumber,
condition: componentInstances.condition,
componentId: components.id,
title: components.title,
componentType: components.componentType,
condition: components.condition
componentType: components.componentType
})
.from(components)
.where(eq(components.currentDeviceId, params.id));
.from(componentInstances)
.innerJoin(components, eq(componentInstances.componentId, components.id))
.where(eq(componentInstances.currentDeviceId, params.id));
// Installation history
const history = await db
@@ -102,11 +107,13 @@ export const load: PageServerLoad = async ({ params }) => {
performedAt: installationLog.performedAt,
performedBy: installationLog.performedBy,
notes: installationLog.notes,
componentId: installationLog.componentId,
instanceId: installationLog.instanceId,
instanceNumber: componentInstances.instanceNumber,
componentTitle: components.title
})
.from(installationLog)
.innerJoin(components, eq(installationLog.componentId, components.id))
.innerJoin(componentInstances, eq(installationLog.instanceId, componentInstances.id))
.innerJoin(components, eq(componentInstances.componentId, components.id))
.where(eq(installationLog.deviceId, params.id))
.orderBy(desc(installationLog.performedAt));
+7 -2
View File
@@ -183,11 +183,15 @@
{:else}
<div class="space-y-2">
{#each data.installedComponents as comp}
<a href="/components/{comp.id}"
<a href="/components/{comp.componentId}"
class="flex items-center justify-between rounded-md border border-gray-100 p-3 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/50">
<div>
<span class="font-medium text-gray-900 dark:text-white">{comp.title}</span>
<span class="ml-1 text-xs text-gray-400 dark:text-gray-500">#{comp.instanceNumber}</span>
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{comp.componentType}</span>
{#if comp.serialNumber}
<span class="ml-2 font-mono text-xs text-gray-400">{comp.serialNumber}</span>
{/if}
</div>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{comp.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
@@ -219,7 +223,8 @@
<div class="flex-1">
<p class="text-sm text-gray-700 dark:text-gray-300">
<span class="font-medium capitalize">{entry.action}</span>
<a href="/components/{entry.componentId}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{entry.componentTitle}</a>
<span class="text-blue-600 dark:text-blue-400">{entry.componentTitle}</span>
<span class="text-gray-400">#{entry.instanceNumber}</span>
</p>
{#if entry.notes}
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{entry.notes}</p>
@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { installationLog, components, devices } from '$lib/server/db/schema.js';
import { installationLog, componentInstances, components, devices } from '$lib/server/db/schema.js';
import { eq, desc, count } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url }) => {
@@ -16,13 +16,15 @@ export const load: PageServerLoad = async ({ url }) => {
performedAt: installationLog.performedAt,
performedBy: installationLog.performedBy,
notes: installationLog.notes,
componentId: installationLog.componentId,
instanceId: installationLog.instanceId,
instanceNumber: componentInstances.instanceNumber,
componentTitle: components.title,
deviceId: installationLog.deviceId,
deviceTitle: devices.title
})
.from(installationLog)
.innerJoin(components, eq(installationLog.componentId, components.id))
.innerJoin(componentInstances, eq(installationLog.instanceId, componentInstances.id))
.innerJoin(components, eq(componentInstances.componentId, components.id))
.innerJoin(devices, eq(installationLog.deviceId, devices.id))
.orderBy(desc(installationLog.performedAt))
.limit(pageSize)
+2 -1
View File
@@ -34,7 +34,8 @@
</span>
<div class="flex-1">
<p class="text-sm text-gray-700 dark:text-gray-300">
<a href="/components/{entry.componentId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{entry.componentTitle}</a>
<span class="font-medium">{entry.componentTitle}</span>
<span class="text-gray-400">#{entry.instanceNumber}</span>
{entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'}
<a href="/devices/{entry.deviceId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{entry.deviceTitle}</a>
</p>
@@ -1,14 +1,14 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, installationLog, locations } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { components, componentInstances, devices, installationLog, locations } from '$lib/server/db/schema.js';
import { eq, isNull } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import pg from 'pg';
import { env } from '$env/dynamic/private';
const installSchema = z.object({
componentId: z.string().uuid('Select a component'),
instanceId: z.string().uuid('Select an instance'),
deviceId: z.string().uuid('Select a device'),
action: z.enum(['installed', 'removed', 'swapped']),
performedBy: z.string().optional(),
@@ -22,15 +22,21 @@ export const load: PageServerLoad = async ({ url }) => {
.from(devices)
.where(eq(devices.disabled, false))
.orderBy(devices.title);
const componentList = await db
// Available instances (not installed) grouped by component
const availableInstances = await db
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
currentDeviceId: components.currentDeviceId
id: componentInstances.id,
instanceNumber: componentInstances.instanceNumber,
serialNumber: componentInstances.serialNumber,
componentId: componentInstances.componentId,
componentTitle: components.title,
currentDeviceId: componentInstances.currentDeviceId
})
.from(components)
.orderBy(components.title);
.from(componentInstances)
.innerJoin(components, eq(componentInstances.componentId, components.id))
.orderBy(components.title, componentInstances.instanceNumber);
const locationList = await db
.select({ id: locations.id, name: locations.name, parentId: locations.parentId })
.from(locations)
@@ -38,10 +44,10 @@ export const load: PageServerLoad = async ({ url }) => {
return {
devices: deviceList,
components: componentList,
instances: availableInstances,
locations: locationList,
prefill: {
componentId: url.searchParams.get('componentId') ?? '',
instanceId: url.searchParams.get('instanceId') ?? '',
deviceId: url.searchParams.get('deviceId') ?? '',
action: url.searchParams.get('action') ?? 'installed'
}
@@ -60,71 +66,57 @@ export const actions: Actions = {
const data = result.data;
// Validate component exists
const [component] = await db
.select({ id: components.id, currentDeviceId: components.currentDeviceId, title: components.title })
.from(components)
.where(eq(components.id, data.componentId));
// Validate instance
const [instance] = await db
.select({ id: componentInstances.id, currentDeviceId: componentInstances.currentDeviceId })
.from(componentInstances)
.where(eq(componentInstances.id, data.instanceId));
if (!component) {
return fail(400, { error: 'Component not found', values: raw });
}
if (!instance) return fail(400, { error: 'Instance not found', values: raw });
// Validate device exists
// Validate device
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(eq(devices.id, data.deviceId));
if (!device) {
return fail(400, { error: 'Device not found', values: raw });
if (!device) return fail(400, { error: 'Device not found', values: raw });
// Business logic
if (data.action === 'installed' && instance.currentDeviceId) {
return fail(400, { error: 'This instance is already installed in a device. Remove it first.', values: raw });
}
if (data.action === 'removed' && instance.currentDeviceId !== data.deviceId) {
return fail(400, { error: 'This instance is not installed in this device.', values: raw });
}
// Business logic validation
if (data.action === 'installed' && component.currentDeviceId) {
return fail(400, {
error: `${component.title} is already installed in a device. Remove it first.`,
values: raw
});
}
if (data.action === 'removed' && component.currentDeviceId !== data.deviceId) {
return fail(400, {
error: `${component.title} is not installed in this device.`,
values: raw
});
}
// Execute in a transaction using raw pg client
// Transaction
const pool = new pg.Pool({ connectionString: env.DATABASE_URL });
const client = await pool.connect();
try {
await client.query('BEGIN');
// Insert log entry
await client.query(
`INSERT INTO installation_log (id, component_id, device_id, action, performed_by, notes, performed_at)
`INSERT INTO installation_log (id, instance_id, device_id, action, performed_by, notes, performed_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, NOW())`,
[data.componentId, data.deviceId, data.action, data.performedBy || null, data.notes || null]
[data.instanceId, data.deviceId, data.action, data.performedBy || null, data.notes || null]
);
// Update component's current location
if (data.action === 'installed') {
await client.query(
`UPDATE components SET current_device_id = $1, location_id = NULL, updated_at = NOW() WHERE id = $2`,
[data.deviceId, data.componentId]
`UPDATE component_instances SET current_device_id = $1, location_id = NULL, updated_at = NOW() WHERE id = $2`,
[data.deviceId, data.instanceId]
);
} else if (data.action === 'removed') {
await client.query(
`UPDATE components SET current_device_id = NULL, location_id = $1, updated_at = NOW() WHERE id = $2`,
[data.storageLocationId || null, data.componentId]
`UPDATE component_instances SET current_device_id = NULL, location_id = $1, updated_at = NOW() WHERE id = $2`,
[data.storageLocationId || null, data.instanceId]
);
} else if (data.action === 'swapped') {
// For swap: mark as installed in the target device
await client.query(
`UPDATE components SET current_device_id = $1, location_id = NULL, updated_at = NOW() WHERE id = $2`,
[data.deviceId, data.componentId]
`UPDATE component_instances SET current_device_id = $1, location_id = NULL, updated_at = NOW() WHERE id = $2`,
[data.deviceId, data.instanceId]
);
}
@@ -6,6 +6,13 @@
let { data, form } = $props();
let action = $state(form?.values?.action ?? data.prefill.action ?? 'installed');
// Filter instances based on action
const filteredInstances = $derived(
action === 'installed'
? data.instances.filter((i) => !i.currentDeviceId)
: data.instances.filter((i) => i.currentDeviceId)
);
</script>
<svelte:head>
@@ -34,17 +41,20 @@
</div>
<div class="mb-4">
<label for="componentId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Component *</label>
<select id="componentId" name="componentId" required
<label for="instanceId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Component Instance *</label>
<select id="instanceId" name="instanceId" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select component...</option>
{#each data.components as comp}
<option value={comp.id} selected={comp.id === (form?.values?.componentId ?? data.prefill.componentId)}>
{comp.title} ({comp.componentType})
{#if comp.currentDeviceId}[installed]{/if}
<option value="">Select instance...</option>
{#each filteredInstances as inst}
<option value={inst.id} selected={inst.id === (form?.values?.instanceId ?? data.prefill.instanceId)}>
{inst.componentTitle} #{inst.instanceNumber}
{#if inst.serialNumber}(S/N: {inst.serialNumber}){/if}
</option>
{/each}
</select>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{filteredInstances.length} {action === 'installed' ? 'available' : 'installed'} instance{filteredInstances.length !== 1 ? 's' : ''}
</p>
</div>
<div class="mb-4">
+6 -6
View File
@@ -1,6 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { locations, devices, components } from '$lib/server/db/schema.js';
import { locations, devices, componentInstances } from '$lib/server/db/schema.js';
import { eq, count, isNull } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
@@ -23,10 +23,10 @@ export const load: PageServerLoad = async () => {
.groupBy(devices.locationId);
const componentCounts = await db
.select({ locationId: components.locationId, count: count() })
.from(components)
.where(isNull(components.currentDeviceId))
.groupBy(components.locationId);
.select({ locationId: componentInstances.locationId, count: count() })
.from(componentInstances)
.where(isNull(componentInstances.currentDeviceId))
.groupBy(componentInstances.locationId);
const dcMap: Record<string, number> = {};
for (const r of deviceCounts) {
@@ -91,7 +91,7 @@ export const actions: Actions = {
const id = formData.get('id') as string;
// Clear references first
await db.update(devices).set({ locationId: null }).where(eq(devices.locationId, id));
await db.update(components).set({ locationId: null }).where(eq(components.locationId, id));
await db.update(componentInstances).set({ locationId: null }).where(eq(componentInstances.locationId, id));
await db.delete(locations).where(eq(locations.id, id));
return { deleted: true };
}
@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { locations, devices, components } from '$lib/server/db/schema.js';
import { locations, devices, components, componentInstances } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
@@ -43,17 +43,20 @@ export const load: PageServerLoad = async ({ params }) => {
.where(and(eq(devices.locationId, params.id), eq(devices.disabled, false)))
.orderBy(devices.title);
// Components stored at this location (not installed in a device)
// Component instances stored at this location (not installed in a device)
const componentList = await db
.select({
id: components.id,
id: componentInstances.id,
componentId: componentInstances.componentId,
instanceNumber: componentInstances.instanceNumber,
title: components.title,
componentType: components.componentType,
condition: components.condition
condition: componentInstances.condition
})
.from(components)
.where(and(eq(components.locationId, params.id), isNull(components.currentDeviceId)))
.orderBy(components.title);
.from(componentInstances)
.innerJoin(components, eq(componentInstances.componentId, components.id))
.where(and(eq(componentInstances.locationId, params.id), isNull(componentInstances.currentDeviceId)))
.orderBy(components.title, componentInstances.instanceNumber);
return { location, parentName, children, devices: deviceList, components: componentList };
};
+2 -1
View File
@@ -106,9 +106,10 @@
{#each data.components as comp}
<tr class="border-b border-gray-100 last:border-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/30">
<td class="px-4 py-3">
<a href="/components/{comp.id}" class="font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
<a href="/components/{comp.componentId}" class="font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{comp.title}
</a>
<span class="ml-1 text-xs text-gray-400">#{comp.instanceNumber}</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{comp.componentType}</td>
<td class="px-4 py-3">
@@ -14,8 +14,7 @@ export const load: PageServerLoad = async ({ params }) => {
title: components.title,
componentType: components.componentType,
brand: components.brand,
partNumber: components.partNumber,
serialNumber: components.serialNumber
partNumber: components.partNumber
})
.from(components)
.where(eq(components.id, params.id));
@@ -52,9 +52,6 @@
{#if data.component.partNumber}
<div style="font-size: 6pt; color: #666; margin-top: 0.3mm;">P/N: {data.component.partNumber}</div>
{/if}
{#if data.component.serialNumber}
<div style="font-size: 6pt; color: #666;">S/N: {data.component.serialNumber}</div>
{/if}
<div style="margin-top: 1mm;">
<img src={data.barcodeDataUrl} alt={data.shortId}
style="height: 7mm; width: auto; max-width: 55mm; display: block;" />
+1 -2
View File
@@ -11,8 +11,7 @@ export const GET: RequestHandler = async ({ url }) => {
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
currentDeviceId: components.currentDeviceId
componentType: components.componentType
})
.from(components)
.where(