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:
@@ -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')`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}· {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} › {/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}· {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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user