diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 3f14bfa..e5ace3d 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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')`) diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index 481febc..e9f2790 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -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) diff --git a/src/routes/(app)/+page.server.ts b/src/routes/(app)/+page.server.ts index a56feee..24b2455 100644 --- a/src/routes/(app)/+page.server.ts +++ b/src/routes/(app)/+page.server.ts @@ -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); diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 01b0af6..9bab5c8 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -71,7 +71,7 @@

- {entry.componentTitle} + {entry.componentTitle} #{entry.instanceNumber} {entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'} {entry.deviceTitle}

diff --git a/src/routes/(app)/components/+page.server.ts b/src/routes/(app)/components/+page.server.ts index b2e75da..ce97d65 100644 --- a/src/routes/(app)/components/+page.server.ts +++ b/src/routes/(app)/components/+page.server.ts @@ -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 = {}; + + if (componentIds.length > 0) { + const counts = await db + .select({ + componentId: componentInstances.componentId, + total: count(), + installed: sql`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 } }; }; diff --git a/src/routes/(app)/components/+page.svelte b/src/routes/(app)/components/+page.svelte index 39706e1..d78f807 100644 --- a/src/routes/(app)/components/+page.svelte +++ b/src/routes/(app)/components/+page.svelte @@ -1,7 +1,7 @@ @@ -16,19 +24,24 @@

{c.title}

- - {c.condition} + + {c.componentType}

- {c.componentType} - {#if c.brand}· {c.brand}{/if} + {#if c.brand}{c.brand}{/if} + {#if c.partNumber} · P/N: {c.partNumber}{/if}

+ +
+ {totalCount} total + {#if installedCount > 0} + {installedCount} installed + {/if} + + {availableCount} available + +
Label - - {#if c.currentDeviceId} - - Remove from Device - - {:else} - - Install into Device - - {/if}
+ {#if form?.error} +
{form.error}
+ {/if} +
- +
-

Current Location

- {#if c.currentDeviceId} -

- Installed in - {c.deviceTitle} -

- {:else if c.locationName} -

In storage at {#if data.parentLocationName}{data.parentLocationName} › {/if}{c.locationName}

+
+

Instances

+ +
+ + {#if showAddInstances} +
+
+ + +
+ +
+ {/if} + + {#if data.instances.length === 0} +

No instances. Add some above.

{:else} -

In storage (no location set)

+
+ {#each data.instances as inst} + {#if editingInstanceId === inst.id} +
{ + 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"> + +
+
+ Serial Number + +
+
+ Condition + +
+
+
+
+ Firmware + +
+
+ Notes + +
+
+
+ + +
+
+ {:else} +
+ #{inst.instanceNumber} +
+
+ {inst.condition} + {#if inst.serialNumber} + S/N: {inst.serialNumber} + {/if} + {#if inst.firmwareVersion} + FW: {inst.firmwareVersion} + {/if} +
+
+ {#if inst.currentDeviceId} + Installed in {inst.deviceTitle} + {:else if inst.locationName} + {inst.locationName} + {:else} + In storage + {/if} + {#if inst.notes} + · {inst.notes} + {/if} +
+
+
+ {#if !inst.currentDeviceId} + + {:else} + + {/if} + +
+ + +
+
+
+ {/if} + {/each} +
{/if}
@@ -90,6 +209,7 @@

{entry.action} + #{entry.instanceNumber} {entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'} {entry.deviceTitle}

@@ -98,7 +218,7 @@ {/if}

{timeAgo(entry.performedAt)} - {#if entry.performedBy}· {entry.performedBy}{/if} + {#if entry.performedBy}· {entry.performedBy}{/if}

@@ -119,24 +239,18 @@
{c.partNumber}
{/if} - {#if c.serialNumber} -
-
Serial Number
-
{c.serialNumber}
-
- {/if} - {#if c.firmwareVersion} -
-
Firmware
-
{c.firmwareVersion}
-
- {/if} {#if c.specs}
Specs
{c.specs}
{/if} + {#if c.defaultFirmwareVersion} +
+
Default Firmware
+
{c.defaultFirmwareVersion}
+
+ {/if}
Added
{formatDate(c.createdAt)}
@@ -153,7 +267,6 @@ {showDocForm ? 'Cancel' : 'Upload'}
- {#if showDocForm}
@@ -162,7 +275,6 @@
{/if} - {#if data.documents.length === 0}

No documents.

{:else} @@ -178,10 +290,8 @@
-
diff --git a/src/routes/(app)/components/[id]/edit/+page.server.ts b/src/routes/(app)/components/[id]/edit/+page.server.ts index 51a6d8d..3b86f9c 100644 --- a/src/routes/(app)/components/[id]/edit/+page.server.ts +++ b/src/routes/(app)/components/[id]/edit/+page.server.ts @@ -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)); diff --git a/src/routes/(app)/components/[id]/edit/+page.svelte b/src/routes/(app)/components/[id]/edit/+page.svelte index 4cea9d9..07ec490 100644 --- a/src/routes/(app)/components/[id]/edit/+page.svelte +++ b/src/routes/(app)/components/[id]/edit/+page.svelte @@ -20,7 +20,7 @@
-

Component Info

+

Component Type

@@ -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" />
-
-
- - -
-
- - -
+
+ +
-
+
-
- - -
-
- -
- -
@@ -86,12 +64,29 @@
- {#if !c.currentDeviceId} -
-

Storage Location

- +
+

Defaults for New Instances

+
+
+ + +
+
+ + +
- {/if} +
+ Default Location + +
+
diff --git a/src/routes/(app)/components/[id]/label/+page.server.ts b/src/routes/(app)/components/[id]/label/+page.server.ts index 48769bf..c7fbdc8 100644 --- a/src/routes/(app)/components/[id]/label/+page.server.ts +++ b/src/routes/(app)/components/[id]/label/+page.server.ts @@ -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)); diff --git a/src/routes/(app)/components/[id]/label/+page.svelte b/src/routes/(app)/components/[id]/label/+page.svelte index 3228bf4..f7603c0 100644 --- a/src/routes/(app)/components/[id]/label/+page.svelte +++ b/src/routes/(app)/components/[id]/label/+page.svelte @@ -35,8 +35,8 @@ {#if data.component.partNumber}

P/N: {data.component.partNumber}

{/if} - {#if data.component.serialNumber} -

S/N: {data.component.serialNumber}

+ {#if data.component.partNumber} +

P/N: {data.component.partNumber}

{/if}

{data.component.id.slice(0, 8)}

diff --git a/src/routes/(app)/components/new/+page.server.ts b/src/routes/(app)/components/new/+page.server.ts index 644723e..1f299b7 100644 --- a/src/routes/(app)/components/new/+page.server.ts +++ b/src/routes/(app)/components/new/+page.server.ts @@ -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}`); } }; diff --git a/src/routes/(app)/components/new/+page.svelte b/src/routes/(app)/components/new/+page.svelte index cd22ce3..1b62704 100644 --- a/src/routes/(app)/components/new/+page.svelte +++ b/src/routes/(app)/components/new/+page.svelte @@ -19,13 +19,13 @@
-

Component Info

+

Component Type

+ placeholder="e.g. 4MB 72-pin SIMM" />
@@ -39,17 +39,14 @@
- - + + +

Creates this many individual instances

-
+
-
- - -
-
- -
- -
+ placeholder="e.g. 32MB 72-pin SIMM, 60ns">{form?.values?.specs ?? ''}
@@ -88,20 +74,26 @@
-

Location

-
- - +

Defaults for Instances

+
+
+ + +
+
+ + +
Storage Location - +
diff --git a/src/routes/(app)/devices/[id]/+page.server.ts b/src/routes/(app)/devices/[id]/+page.server.ts index a48db7f..4160216 100644 --- a/src/routes/(app)/devices/[id]/+page.server.ts +++ b/src/routes/(app)/devices/[id]/+page.server.ts @@ -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)); diff --git a/src/routes/(app)/devices/[id]/+page.svelte b/src/routes/(app)/devices/[id]/+page.svelte index 8a65e48..7cae9e9 100644 --- a/src/routes/(app)/devices/[id]/+page.svelte +++ b/src/routes/(app)/devices/[id]/+page.svelte @@ -183,11 +183,15 @@ {:else}
{#each data.installedComponents as comp} -
{comp.title} + #{comp.instanceNumber} {comp.componentType} + {#if comp.serialNumber} + {comp.serialNumber} + {/if}

{entry.action} - {entry.componentTitle} + {entry.componentTitle} + #{entry.instanceNumber}

{#if entry.notes}

{entry.notes}

diff --git a/src/routes/(app)/installations/+page.server.ts b/src/routes/(app)/installations/+page.server.ts index 51c7370..7a68f43 100644 --- a/src/routes/(app)/installations/+page.server.ts +++ b/src/routes/(app)/installations/+page.server.ts @@ -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) diff --git a/src/routes/(app)/installations/+page.svelte b/src/routes/(app)/installations/+page.svelte index 435f4b7..48133dc 100644 --- a/src/routes/(app)/installations/+page.svelte +++ b/src/routes/(app)/installations/+page.svelte @@ -34,7 +34,8 @@

- {entry.componentTitle} + {entry.componentTitle} + #{entry.instanceNumber} {entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'} {entry.deviceTitle}

diff --git a/src/routes/(app)/installations/new/+page.server.ts b/src/routes/(app)/installations/new/+page.server.ts index bc3a738..39a6698 100644 --- a/src/routes/(app)/installations/new/+page.server.ts +++ b/src/routes/(app)/installations/new/+page.server.ts @@ -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] ); } diff --git a/src/routes/(app)/installations/new/+page.svelte b/src/routes/(app)/installations/new/+page.svelte index bae5905..b865191 100644 --- a/src/routes/(app)/installations/new/+page.svelte +++ b/src/routes/(app)/installations/new/+page.svelte @@ -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) + ); @@ -34,17 +41,20 @@
- - - - {#each data.components as comp} - + {#each filteredInstances as inst} + {/each} +

+ {filteredInstances.length} {action === 'installed' ? 'available' : 'installed'} instance{filteredInstances.length !== 1 ? 's' : ''} +

diff --git a/src/routes/(app)/locations/+page.server.ts b/src/routes/(app)/locations/+page.server.ts index ab5ad01..9b89993 100644 --- a/src/routes/(app)/locations/+page.server.ts +++ b/src/routes/(app)/locations/+page.server.ts @@ -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 = {}; 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 }; } diff --git a/src/routes/(app)/locations/[id]/+page.server.ts b/src/routes/(app)/locations/[id]/+page.server.ts index 6fdf81e..c492107 100644 --- a/src/routes/(app)/locations/[id]/+page.server.ts +++ b/src/routes/(app)/locations/[id]/+page.server.ts @@ -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 }; }; diff --git a/src/routes/(app)/locations/[id]/+page.svelte b/src/routes/(app)/locations/[id]/+page.svelte index f1b4b41..549ad55 100644 --- a/src/routes/(app)/locations/[id]/+page.svelte +++ b/src/routes/(app)/locations/[id]/+page.svelte @@ -106,9 +106,10 @@ {#each data.components as comp} - + {comp.title} + #{comp.instanceNumber} {comp.componentType} diff --git a/src/routes/(print)/print/component/[id]/+page.server.ts b/src/routes/(print)/print/component/[id]/+page.server.ts index 7104f7d..4b3b90a 100644 --- a/src/routes/(print)/print/component/[id]/+page.server.ts +++ b/src/routes/(print)/print/component/[id]/+page.server.ts @@ -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)); diff --git a/src/routes/(print)/print/component/[id]/+page.svelte b/src/routes/(print)/print/component/[id]/+page.svelte index c3567f7..f1bb2f8 100644 --- a/src/routes/(print)/print/component/[id]/+page.svelte +++ b/src/routes/(print)/print/component/[id]/+page.svelte @@ -52,9 +52,6 @@ {#if data.component.partNumber}
P/N: {data.component.partNumber}
{/if} - {#if data.component.serialNumber} -
S/N: {data.component.serialNumber}
- {/if}
{data.shortId} diff --git a/src/routes/api/components/+server.ts b/src/routes/api/components/+server.ts index 3d1f483..74b3e8c 100644 --- a/src/routes/api/components/+server.ts +++ b/src/routes/api/components/+server.ts @@ -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(