Initial commit: buildfor_life_repair inventory system

SvelteKit + PostgreSQL app for tracking vintage computers, audio equipment,
components, and installation history. Features device/component CRUD, operation
logs, QR code labels, global search, image uploads, and dark mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 17:11:05 +07:00
commit 6f0e0ad6c6
64 changed files with 8996 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices, deviceImages, locations } from '$lib/server/db/schema.js';
import { eq, ilike, or, count, desc, and, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url }) => {
const category = url.searchParams.get('category');
const condition = url.searchParams.get('condition');
const search = url.searchParams.get('q');
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1));
const pageSize = 24;
const conditions = [eq(devices.disabled, false)];
if (category) {
conditions.push(eq(devices.category, category));
}
if (condition === 'needs-repair') {
conditions.push(
or(eq(devices.condition, 'In Repair'), eq(devices.condition, 'Waiting for Repair'))!
);
} else if (condition) {
conditions.push(eq(devices.condition, condition));
}
if (search) {
conditions.push(
or(
ilike(devices.title, `%${search}%`),
ilike(devices.brand, `%${search}%`),
ilike(devices.model, `%${search}%`),
ilike(devices.serialNumber, `%${search}%`)
)!
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db.select({ value: count() }).from(devices).where(where);
const total = totalResult?.value ?? 0;
const deviceList = await db
.select({
id: devices.id,
title: devices.title,
category: devices.category,
brand: devices.brand,
model: devices.model,
condition: devices.condition,
year: devices.year,
locationName: locations.name
})
.from(devices)
.leftJoin(locations, eq(devices.locationId, locations.id))
.where(where)
.orderBy(desc(devices.updatedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
// Fetch first image for each device
const deviceIds = deviceList.map((d) => d.id);
let imageMap: Record<string, string> = {};
if (deviceIds.length > 0) {
const images = await db
.select({
deviceId: deviceImages.deviceId,
thumbnailPath: deviceImages.thumbnailPath,
filePath: deviceImages.filePath
})
.from(deviceImages)
.where(sql`${deviceImages.deviceId} IN ${deviceIds}`)
.orderBy(deviceImages.sortOrder);
for (const img of images) {
if (!imageMap[img.deviceId]) {
imageMap[img.deviceId] = img.thumbnailPath ?? img.filePath;
}
}
}
return {
devices: deviceList.map((d) => ({
...d,
thumbnail: imageMap[d.id] ?? null
})),
total,
page,
pageSize,
filters: { category, condition, search }
};
};
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { DEVICE_CATEGORIES, DEVICE_CONDITIONS } from '$lib/constants.js';
let { data } = $props();
let search = $state(data.filters.search ?? '');
function applyFilter(key: string, value: string | null) {
const url = new URL($page.url);
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
url.searchParams.delete('page');
goto(url.toString());
}
function handleSearch(e: Event) {
e.preventDefault();
applyFilter('q', search || null);
}
const totalPages = $derived(Math.ceil(data.total / data.pageSize));
</script>
<svelte:head>
<title>Devices - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Devices</h1>
<a
href="/devices/new"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Device
</a>
</div>
<!-- Filters -->
<div class="mb-6 flex flex-wrap items-center gap-3">
<form onsubmit={handleSearch} class="flex-1">
<input
type="text"
bind:value={search}
placeholder="Search devices..."
class="w-full max-w-sm 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"
/>
</form>
<select
onchange={(e) => applyFilter('category', (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 Categories</option>
{#each DEVICE_CATEGORIES as cat}
<option value={cat} selected={data.filters.category === cat}>{cat}</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>
<option value="needs-repair" selected={data.filters.condition === 'needs-repair'}>Needs Repair</option>
{#each DEVICE_CONDITIONS as cond}
<option value={cond} selected={data.filters.condition === cond}>{cond}</option>
{/each}
</select>
</div>
<!-- Results count -->
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">{data.total} device{data.total !== 1 ? 's' : ''}</p>
<!-- Device Grid -->
{#if data.devices.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No devices found.</p>
<a href="/devices/new" class="mt-2 inline-block text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">Add your first device</a>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.devices as device}
<a
href="/devices/{device.id}"
class="rounded-lg border border-gray-200 bg-white transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<!-- Thumbnail -->
<div class="flex h-40 items-center justify-center overflow-hidden rounded-t-lg bg-gray-100 dark:bg-gray-700">
{#if device.thumbnail}
<img src={device.thumbnail} alt={device.title} class="h-full w-full object-cover" />
{:else}
<svg class="h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{/if}
</div>
<div class="p-4">
<h3 class="font-medium text-gray-900 dark:text-white">{device.title}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{[device.brand, device.model].filter(Boolean).join(' ') || device.category}
</p>
<div class="mt-2 flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{device.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{device.condition === 'In Repair' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : ''}
{device.condition === 'Waiting for Repair' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' : ''}
{device.condition === 'Waiting to be Tested' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
{device.condition === 'Unrepairable' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
">
{device.condition}
</span>
{#if device.year}
<span class="text-xs text-gray-400 dark:text-gray-500">{device.year}</span>
{/if}
</div>
{#if device.locationName}
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{device.locationName}</p>
{/if}
</div>
</a>
{/each}
</div>
{/if}
<!-- Pagination -->
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
{#if data.page > 1}
<a
href="?{new URLSearchParams({ ...(data.filters.category ? { category: data.filters.category } : {}), ...(data.filters.condition ? { condition: data.filters.condition } : {}), ...(data.filters.search ? { q: data.filters.search } : {}), page: String(data.page - 1) }).toString()}"
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"
>
Previous
</a>
{/if}
<span class="text-sm text-gray-500 dark:text-gray-400">Page {data.page} of {totalPages}</span>
{#if data.page < totalPages}
<a
href="?{new URLSearchParams({ ...(data.filters.category ? { category: data.filters.category } : {}), ...(data.filters.condition ? { condition: data.filters.condition } : {}), ...(data.filters.search ? { q: data.filters.search } : {}), page: String(data.page + 1) }).toString()}"
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"
>
Next
</a>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,223 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import {
devices,
computerDetails,
deviceImages,
deviceDocuments,
components,
installationLog,
deviceLog,
locations
} from '$lib/server/db/schema.js';
import { eq, desc } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { saveImage, saveDocument, deleteFile } from '$lib/server/uploads.js';
export const load: PageServerLoad = async ({ params }) => {
const [device] = await db
.select({
id: devices.id,
title: devices.title,
category: devices.category,
brand: devices.brand,
model: devices.model,
serialNumber: devices.serialNumber,
year: devices.year,
condition: devices.condition,
voltage: devices.voltage,
frequency: devices.frequency,
origin: devices.origin,
faultDescription: devices.faultDescription,
repairNotes: devices.repairNotes,
initialCondition: devices.initialCondition,
generalNotes: devices.generalNotes,
locationId: devices.locationId,
locationName: locations.name,
disabled: devices.disabled,
createdAt: devices.createdAt,
updatedAt: devices.updatedAt
})
.from(devices)
.leftJoin(locations, eq(devices.locationId, locations.id))
.where(eq(devices.id, params.id));
if (!device) error(404, 'Device not found');
if (device.disabled) error(404, 'Device not found');
// Computer details
let compDetails = null;
if (device.category === 'Computer') {
const [cd] = await db
.select()
.from(computerDetails)
.where(eq(computerDetails.deviceId, params.id));
compDetails = cd ?? null;
}
// Images
const images = await db
.select()
.from(deviceImages)
.where(eq(deviceImages.deviceId, params.id))
.orderBy(deviceImages.sortOrder);
// Documents
const documents = await db
.select()
.from(deviceDocuments)
.where(eq(deviceDocuments.deviceId, params.id));
// Installed components
const installedComponents = await db
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
condition: components.condition
})
.from(components)
.where(eq(components.currentDeviceId, params.id));
// Installation history
const history = await db
.select({
id: installationLog.id,
action: installationLog.action,
performedAt: installationLog.performedAt,
performedBy: installationLog.performedBy,
notes: installationLog.notes,
componentId: installationLog.componentId,
componentTitle: components.title
})
.from(installationLog)
.innerJoin(components, eq(installationLog.componentId, components.id))
.where(eq(installationLog.deviceId, params.id))
.orderBy(desc(installationLog.performedAt));
// Device operation/repair log
const operationLog = await db
.select()
.from(deviceLog)
.where(eq(deviceLog.deviceId, params.id))
.orderBy(desc(deviceLog.performedAt));
return {
device,
computerDetails: compDetails,
images,
documents,
installedComponents,
history,
operationLog
};
};
export const actions: Actions = {
updateStatus: async ({ request, params }) => {
const formData = await request.formData();
const condition = formData.get('condition') as string;
const repairNotes = formData.get('repairNotes') as string;
await db
.update(devices)
.set({
condition,
repairNotes: repairNotes || null,
updatedAt: new Date()
})
.where(eq(devices.id, params.id));
return { statusUpdated: true };
},
addLogEntry: async ({ request, params }) => {
const formData = await request.formData();
const type = formData.get('type') as string;
const description = formData.get('description') as string;
const conditionAfter = formData.get('conditionAfter') as string;
const performedBy = formData.get('performedBy') as string;
if (!description) {
return fail(400, { error: 'Description is required' });
}
await db.insert(deviceLog).values({
deviceId: params.id,
type: type || 'other',
description,
conditionAfter: conditionAfter || null,
performedBy: performedBy || null
});
// Update device condition if provided
if (conditionAfter) {
await db
.update(devices)
.set({ condition: conditionAfter, updatedAt: new Date() })
.where(eq(devices.id, params.id));
}
return { logAdded: true };
},
uploadImage: async ({ request, params }) => {
const formData = await request.formData();
const file = formData.get('image') as File;
if (!file || file.size === 0) return fail(400, { error: 'No file selected' });
const { filePath, thumbnailPath } = await saveImage(file, 'devices');
const caption = formData.get('caption') as string;
await db.insert(deviceImages).values({
deviceId: params.id,
filePath,
thumbnailPath,
caption: caption || null
});
return { imageUploaded: true };
},
deleteImage: async ({ request }) => {
const formData = await request.formData();
const imageId = formData.get('imageId') as string;
const [img] = await db.select().from(deviceImages).where(eq(deviceImages.id, imageId));
if (img) {
await deleteFile(img.filePath);
if (img.thumbnailPath) await deleteFile(img.thumbnailPath);
await db.delete(deviceImages).where(eq(deviceImages.id, imageId));
}
return { imageDeleted: true };
},
uploadDocument: async ({ request, params }) => {
const formData = await request.formData();
const file = formData.get('document') as File;
if (!file || file.size === 0) return fail(400, { error: 'No file selected' });
const { filePath, originalFilename } = await saveDocument(file);
const description = formData.get('description') as string;
await db.insert(deviceDocuments).values({
deviceId: params.id,
filePath,
originalFilename,
fileType: file.type,
description: description || null
});
return { documentUploaded: true };
},
disable: async ({ params }) => {
await db
.update(devices)
.set({ disabled: true, updatedAt: new Date() })
.where(eq(devices.id, params.id));
redirect(303, '/devices');
}
};
+447
View File
@@ -0,0 +1,447 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { DEVICE_CONDITIONS, DEVICE_LOG_TYPES } from '$lib/constants.js';
import { formatDate, timeAgo } from '$lib/utils/date.js';
let { data } = $props();
let showStatusForm = $state(false);
let showUploadForm = $state(false);
let showDocForm = $state(false);
let showDeleteConfirm = $state(false);
let showLogForm = $state(false);
</script>
<svelte:head>
<title>{data.device.title} - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<!-- Header -->
<div class="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<div class="mb-1 flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.device.title}</h1>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{data.device.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{data.device.condition === 'In Repair' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : ''}
{data.device.condition === 'Waiting for Repair' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' : ''}
{data.device.condition === 'Waiting to be Tested' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
{data.device.condition === 'Unrepairable' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
">
{data.device.condition}
</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
{data.device.category}
{#if data.device.brand || data.device.model}
&middot; {[data.device.brand, data.device.model].filter(Boolean).join(' ')}
{/if}
{#if data.device.year}
&middot; {data.device.year}
{/if}
</p>
</div>
<div class="flex gap-2">
<button onclick={() => (showStatusForm = !showStatusForm)}
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">
Update Status
</button>
<a href="/devices/{data.device.id}/edit"
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">
Edit
</a>
<a href="/devices/{data.device.id}/label"
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">
QR Label
</a>
<a href="/installations/new?deviceId={data.device.id}"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Install Component
</a>
<button onclick={() => (showDeleteConfirm = !showDeleteConfirm)}
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">
Delete
</button>
</div>
</div>
<!-- Delete Confirmation -->
{#if showDeleteConfirm}
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 p-5 dark:border-red-800 dark:bg-red-900/20">
<p class="mb-3 text-sm text-red-700 dark:text-red-300">Are you sure you want to delete <strong>{data.device.title}</strong>? This will hide it from all listings.</p>
<div class="flex gap-2">
<form method="POST" action="?/disable" use:enhance>
<button type="submit" class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">Yes, delete</button>
</form>
<button onclick={() => (showDeleteConfirm = false)}
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
Cancel
</button>
</div>
</div>
{/if}
<!-- Quick Status Update -->
{#if showStatusForm}
<div class="mb-6 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">Quick Status Update</h2>
<form method="POST" action="?/updateStatus" use:enhance class="flex flex-wrap items-end gap-3">
<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="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each DEVICE_CONDITIONS as cond}
<option value={cond} selected={cond === data.device.condition}>{cond}</option>
{/each}
</select>
</div>
<div class="flex-1">
<label for="repairNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Repair Notes</label>
<input type="text" id="repairNotes" name="repairNotes" value={data.device.repairNotes ?? ''}
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>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</form>
</div>
{/if}
<div class="grid gap-6 lg:grid-cols-3">
<!-- Main Content (2 cols) -->
<div class="space-y-6 lg:col-span-2">
<!-- Images -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<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">Images</h2>
<button onclick={() => (showUploadForm = !showUploadForm)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showUploadForm ? 'Cancel' : 'Upload'}
</button>
</div>
{#if showUploadForm}
<form method="POST" action="?/uploadImage" enctype="multipart/form-data" use:enhance class="mb-4 flex flex-wrap items-end gap-3">
<input type="file" name="image" accept="image/*" required
class="text-sm text-gray-600 dark:text-gray-400" />
<input type="text" name="caption" placeholder="Caption (optional)"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<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.images.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No images yet.</p>
{:else}
<div class="grid gap-2 sm:grid-cols-3">
{#each data.images as img}
<div class="group relative overflow-hidden rounded-md">
<img src={img.filePath} alt={img.caption ?? data.device.title} class="h-32 w-full object-cover" />
<form method="POST" action="?/deleteImage" use:enhance
class="absolute top-1 right-1 hidden group-hover:block">
<input type="hidden" name="imageId" value={img.id} />
<button type="submit" class="rounded bg-red-600 p-1 text-xs text-white hover:bg-red-700" title="Delete">
<svg class="h-3 w-3" 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>
{/each}
</div>
{/if}
</div>
<!-- Installed Components -->
<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">Installed Components</h2>
{#if data.installedComponents.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No components installed.</p>
{:else}
<div class="space-y-2">
{#each data.installedComponents as comp}
<a href="/components/{comp.id}"
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-2 text-xs text-gray-400 dark:text-gray-500">{comp.componentType}</span>
</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' : ''}
{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>
</a>
{/each}
</div>
{/if}
</div>
<!-- Installation History -->
<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">Installation History</h2>
{#if data.history.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No installation history.</p>
{:else}
<div class="space-y-3">
{#each data.history as entry}
<div class="flex items-start gap-3 border-l-2 pl-3
{entry.action === 'installed' ? 'border-green-400' : ''}
{entry.action === 'removed' ? 'border-red-400' : ''}
{entry.action === 'swapped' ? 'border-blue-400' : ''}
">
<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>
</p>
{#if entry.notes}
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{entry.notes}</p>
{/if}
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{timeAgo(entry.performedAt)}
{#if entry.performedBy}&middot; {entry.performedBy}{/if}
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Initial Condition -->
{#if data.device.initialCondition}
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-5 dark:border-blue-800 dark:bg-blue-900/10">
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-blue-500 dark:text-blue-400">Condition on Intake</h2>
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.device.initialCondition}</p>
</div>
{/if}
<!-- Condition & Repair -->
{#if data.device.faultDescription || data.device.repairNotes}
<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">Condition & Repair</h2>
{#if data.device.faultDescription}
<div class="mb-3">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Fault</span>
<p class="text-sm text-gray-700 dark:text-gray-300">{data.device.faultDescription}</p>
</div>
{/if}
{#if data.device.repairNotes}
<div>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Repair Notes</span>
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.device.repairNotes}</p>
</div>
{/if}
</div>
{/if}
<!-- Operation / Repair Log -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<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">Operation Log</h2>
<button onclick={() => (showLogForm = !showLogForm)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showLogForm ? 'Cancel' : 'Add Entry'}
</button>
</div>
{#if showLogForm}
<form method="POST" action="?/addLogEntry" use:enhance class="mb-4 space-y-3 rounded-md border border-gray-100 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="logType" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Type</label>
<select id="logType" name="type"
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 DEVICE_LOG_TYPES as t}
<option value={t} class="capitalize">{t}</option>
{/each}
</select>
</div>
<div>
<label for="conditionAfter" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition After</label>
<select id="conditionAfter" name="conditionAfter"
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="">No change</option>
{#each DEVICE_CONDITIONS as cond}
<option value={cond} selected={cond === data.device.condition}>{cond}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="logDescription" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description *</label>
<textarea id="logDescription" name="description" rows="3" required
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="What was done, what was found, parts replaced..."></textarea>
</div>
<div>
<label for="logPerformedBy" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Performed By</label>
<input type="text" id="logPerformedBy" name="performedBy"
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>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Add Entry</button>
</form>
{/if}
{#if data.operationLog.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No operations logged.</p>
{:else}
<div class="space-y-3">
{#each data.operationLog as entry}
<div class="flex items-start gap-3 border-l-2 pl-3
{entry.type === 'repair' ? 'border-amber-400' : ''}
{entry.type === 'inspection' ? 'border-blue-400' : ''}
{entry.type === 'cleaning' ? 'border-green-400' : ''}
{entry.type === 'modification' ? 'border-purple-400' : ''}
{entry.type === 'diagnostic' ? 'border-cyan-400' : ''}
{entry.type === 'recap' ? 'border-orange-400' : ''}
{entry.type === 'other' ? 'border-gray-400' : ''}
">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium capitalize
{entry.type === 'repair' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : ''}
{entry.type === 'inspection' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
{entry.type === 'cleaning' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{entry.type === 'modification' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' : ''}
{entry.type === 'diagnostic' ? 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300' : ''}
{entry.type === 'recap' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' : ''}
{entry.type === 'other' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : ''}
">
{entry.type}
</span>
{#if entry.conditionAfter}
<span class="text-xs text-gray-400 dark:text-gray-500">{entry.conditionAfter}</span>
{/if}
</div>
<p class="mt-1 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{entry.description}</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{timeAgo(entry.performedAt)}
{#if entry.performedBy}&middot; {entry.performedBy}{/if}
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Sidebar (1 col) -->
<div class="space-y-6">
<!-- Details -->
<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">Details</h2>
<dl class="space-y-2 text-sm">
{#if data.device.serialNumber}
<div>
<dt class="text-gray-500 dark:text-gray-400">Serial Number</dt>
<dd class="font-mono text-gray-900 dark:text-white">{data.device.serialNumber}</dd>
</div>
{/if}
{#if data.device.voltage}
<div>
<dt class="text-gray-500 dark:text-gray-400">Voltage</dt>
<dd class="text-gray-900 dark:text-white">{data.device.voltage}</dd>
</div>
{/if}
{#if data.device.frequency}
<div>
<dt class="text-gray-500 dark:text-gray-400">Frequency</dt>
<dd class="text-gray-900 dark:text-white">{data.device.frequency}</dd>
</div>
{/if}
{#if data.device.origin}
<div>
<dt class="text-gray-500 dark:text-gray-400">Origin</dt>
<dd class="text-gray-900 dark:text-white">{data.device.origin}</dd>
</div>
{/if}
{#if data.device.locationName}
<div>
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
<dd class="text-gray-900 dark:text-white">{data.device.locationName}</dd>
</div>
{/if}
<div>
<dt class="text-gray-500 dark:text-gray-400">Added</dt>
<dd class="text-gray-900 dark:text-white">{formatDate(data.device.createdAt)}</dd>
</div>
</dl>
</div>
<!-- Computer Details -->
{#if data.computerDetails}
<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">Computer Config</h2>
<dl class="space-y-2 text-sm">
{#if data.computerDetails.osVersion}
<div>
<dt class="text-gray-500 dark:text-gray-400">OS</dt>
<dd class="text-gray-900 dark:text-white">{data.computerDetails.osVersion}</dd>
</div>
{/if}
{#if data.computerDetails.firmwareVersion}
<div>
<dt class="text-gray-500 dark:text-gray-400">Firmware/ROM</dt>
<dd class="font-mono text-gray-900 dark:text-white">{data.computerDetails.firmwareVersion}</dd>
</div>
{/if}
{#if data.computerDetails.installedSoftware}
<div>
<dt class="text-gray-500 dark:text-gray-400">Software</dt>
<dd class="whitespace-pre-wrap text-gray-900 dark:text-white">{data.computerDetails.installedSoftware}</dd>
</div>
{/if}
</dl>
</div>
{/if}
<!-- Documents -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<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">Documents</h2>
<button onclick={() => (showDocForm = !showDocForm)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{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" />
<input type="text" name="description" placeholder="Description"
class="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<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}
<div class="space-y-1">
{#each data.documents as doc}
<a href={doc.filePath} target="_blank"
class="flex items-center gap-2 rounded-md p-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50">
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span class="text-gray-700 dark:text-gray-300">{doc.originalFilename}</span>
</a>
{/each}
</div>
{/if}
</div>
<!-- General Notes -->
{#if data.device.generalNotes}
<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">Notes</h2>
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.device.generalNotes}</p>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,102 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices, computerDetails, locations } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
const deviceSchema = z.object({
title: z.string().min(1, 'Title is required'),
category: z.enum(['Computer', 'Audio Equipment', 'Peripheral', 'Other']),
brand: z.string().optional(),
model: z.string().optional(),
serialNumber: z.string().optional(),
year: z.coerce.number().int().min(1900).max(2100).optional().or(z.literal('')),
condition: z.enum(['Working', 'In Repair', 'Waiting for Repair', 'Waiting to be Tested', 'Unrepairable']),
voltage: z.string().optional(),
frequency: z.string().optional(),
origin: z.string().optional(),
faultDescription: z.string().optional(),
repairNotes: z.string().optional(),
locationId: z.string().uuid().optional().or(z.literal('')),
generalNotes: z.string().optional(),
osVersion: z.string().optional(),
firmwareVersion: z.string().optional(),
installedSoftware: z.string().optional()
});
export const load: PageServerLoad = async ({ params }) => {
const [device] = await db.select().from(devices).where(eq(devices.id, params.id));
if (!device) error(404, 'Device not found');
let compDetails = null;
if (device.category === 'Computer') {
const [cd] = await db.select().from(computerDetails).where(eq(computerDetails.deviceId, params.id));
compDetails = cd ?? null;
}
const locationList = await db.select({ id: locations.id, name: locations.name }).from(locations);
return { device, computerDetails: compDetails, locations: locationList };
};
export const actions: Actions = {
default: async ({ request, params }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
const result = deviceSchema.safeParse(raw);
if (!result.success) {
return fail(400, { error: result.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const data = result.data;
const year = typeof data.year === 'number' ? data.year : null;
const locationId = data.locationId || null;
await db
.update(devices)
.set({
title: data.title,
category: data.category,
brand: data.brand || null,
model: data.model || null,
serialNumber: data.serialNumber || null,
year,
condition: data.condition,
voltage: data.voltage || null,
frequency: data.frequency || null,
origin: data.origin || null,
faultDescription: data.faultDescription || null,
repairNotes: data.repairNotes || null,
locationId,
generalNotes: data.generalNotes || null,
updatedAt: new Date()
})
.where(eq(devices.id, params.id));
// Upsert computer details
if (data.category === 'Computer') {
const [existing] = await db.select().from(computerDetails).where(eq(computerDetails.deviceId, params.id));
if (existing) {
await db
.update(computerDetails)
.set({
osVersion: data.osVersion || null,
firmwareVersion: data.firmwareVersion || null,
installedSoftware: data.installedSoftware || null
})
.where(eq(computerDetails.deviceId, params.id));
} else {
await db.insert(computerDetails).values({
deviceId: params.id,
osVersion: data.osVersion || null,
firmwareVersion: data.firmwareVersion || null,
installedSoftware: data.installedSoftware || null
});
}
}
redirect(303, `/devices/${params.id}`);
}
};
@@ -0,0 +1,180 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { DEVICE_CATEGORIES, DEVICE_CONDITIONS, VOLTAGE_OPTIONS, FREQUENCY_OPTIONS } from '$lib/constants.js';
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
let { data, form } = $props();
const d = data.device;
const cd = data.computerDetails;
let category = $state(form?.values?.category ?? d.category);
let brand = $state(String(form?.values?.brand ?? d.brand ?? ''));
let model = $state(String(form?.values?.model ?? d.model ?? ''));
</script>
<svelte:head>
<title>Edit {d.title} - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Edit {d.title}</h1>
{#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}
<form method="POST" use:enhance class="space-y-6">
<!-- Identity -->
<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">Identity</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 ?? d.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" />
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="category" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Category *</label>
<select id="category" name="category" bind:value={category}
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 DEVICE_CATEGORIES as cat}
<option value={cat}>{cat}</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 DEVICE_CONDITIONS as cond}
<option value={cond} selected={cond === (form?.values?.condition ?? d.condition)}>{cond}</option>
{/each}
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label for="brand" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Brand</label>
<AutocompleteInput id="brand" name="brand" bind:value={brand} placeholder="e.g. Apple"
fetchUrl="/api/devices/brands" />
</div>
<div>
<label for="model" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
<AutocompleteInput id="model" name="model" bind:value={model} placeholder="e.g. Color Classic"
fetchUrl="/api/devices/models" extraParams={brand ? { brand: String(brand) } : {}} />
</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 ?? d.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>
<!-- Technical Specs -->
<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">Technical Specs</h2>
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<div>
<label for="year" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Year</label>
<input type="number" id="year" name="year" value={form?.values?.year ?? d.year ?? ''} min="1900" max="2100"
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="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage</label>
<select id="voltage" name="voltage"
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=""></option>
{#each VOLTAGE_OPTIONS as v}
<option value={v} selected={v === (form?.values?.voltage ?? d.voltage)}>{v}</option>
{/each}
</select>
</div>
<div>
<label for="frequency" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Frequency</label>
<select id="frequency" name="frequency"
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=""></option>
{#each FREQUENCY_OPTIONS as f}
<option value={f} selected={f === (form?.values?.frequency ?? d.frequency)}>{f}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="origin" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Origin</label>
<input type="text" id="origin" name="origin" value={form?.values?.origin ?? d.origin ?? ''}
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>
<!-- Computer Details -->
{#if category === 'Computer'}
<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">Computer Details</h2>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="osVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">OS Version</label>
<input type="text" id="osVersion" name="osVersion" value={form?.values?.osVersion ?? cd?.osVersion ?? ''}
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="firmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware / ROM</label>
<input type="text" id="firmwareVersion" name="firmwareVersion" value={form?.values?.firmwareVersion ?? cd?.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>
<div>
<label for="installedSoftware" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Installed Software</label>
<textarea id="installedSoftware" name="installedSoftware" rows="3"
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">{form?.values?.installedSoftware ?? cd?.installedSoftware ?? ''}</textarea>
</div>
</div>
{/if}
<!-- Condition & Repair -->
<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">Condition & Repair</h2>
<div class="mb-4">
<label for="faultDescription" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Fault Description</label>
<textarea id="faultDescription" name="faultDescription" rows="3"
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">{form?.values?.faultDescription ?? d.faultDescription ?? ''}</textarea>
</div>
<div>
<label for="repairNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Repair Notes</label>
<textarea id="repairNotes" name="repairNotes" rows="3"
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">{form?.values?.repairNotes ?? d.repairNotes ?? ''}</textarea>
</div>
</div>
<!-- Location & Notes -->
<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 & Notes</h2>
<div class="mb-4">
<label for="locationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
<select id="locationId" name="locationId"
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="">No location</option>
{#each data.locations as loc}
<option value={loc.id} selected={loc.id === (form?.values?.locationId ?? d.locationId)}>{loc.name}</option>
{/each}
</select>
</div>
<div>
<label for="generalNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">General Notes</label>
<textarea id="generalNotes" name="generalNotes" rows="3"
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">{form?.values?.generalNotes ?? d.generalNotes ?? ''}</textarea>
</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>
<a href="/devices/{d.id}" class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">Cancel</a>
</div>
</form>
</div>
@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { generateQrSvg } from '$lib/server/qr.js';
import { env } from '$env/dynamic/private';
export const load: PageServerLoad = async ({ params }) => {
const [device] = await db
.select({
id: devices.id,
title: devices.title,
brand: devices.brand,
model: devices.model,
serialNumber: devices.serialNumber,
category: devices.category
})
.from(devices)
.where(eq(devices.id, params.id));
if (!device) error(404, 'Device not found');
const url = `${env.BASE_URL ?? 'http://localhost:5173'}/devices/${params.id}`;
const qrSvg = await generateQrSvg(url);
return { device, qrSvg, deviceUrl: url };
};
@@ -0,0 +1,67 @@
<script lang="ts">
let { data } = $props();
</script>
<svelte:head>
<title>Label - {data.device.title}</title>
<style>
@media print {
nav, header, aside, button, .no-print { display: none !important; }
main { padding: 0 !important; }
body { background: white !important; }
}
</style>
</svelte:head>
<div class="mx-auto max-w-md">
<div class="no-print mb-4 flex items-center justify-between">
<a href="/devices/{data.device.id}" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Back to device</a>
<button onclick={() => window.print()} class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Print Label
</button>
</div>
<!-- Label -->
<div class="rounded-lg border-2 border-dashed border-gray-300 bg-white p-6 dark:border-gray-600 dark:bg-gray-800">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
{@html data.qrSvg}
</div>
<div class="flex-1">
<h2 class="text-lg font-bold text-gray-900 dark:text-white">{data.device.title}</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{[data.device.brand, data.device.model].filter(Boolean).join(' ') || data.device.category}
</p>
{#if data.device.serialNumber}
<p class="mt-1 font-mono text-xs text-gray-500 dark:text-gray-400">S/N: {data.device.serialNumber}</p>
{/if}
<p class="mt-2 font-mono text-xs text-gray-400 dark:text-gray-500">{data.device.id.slice(0, 8)}</p>
</div>
</div>
</div>
<!-- Multiple labels for sheet printing -->
<div class="no-print mt-6">
<h3 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Small labels (cut along lines)</h3>
<div class="grid grid-cols-2 gap-2">
{#each [0, 1, 2, 3] as _}
<div class="rounded border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center gap-2">
<div class="h-16 w-16 flex-shrink-0">
{@html data.qrSvg}
</div>
<div class="min-w-0">
<p class="truncate text-xs font-bold text-gray-900 dark:text-white">{data.device.title}</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{[data.device.brand, data.device.model].filter(Boolean).join(' ')}
</p>
{#if data.device.serialNumber}
<p class="truncate font-mono text-xs text-gray-400">{data.device.serialNumber}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { generateQrSvg } from '$lib/server/qr.js';
import { env } from '$env/dynamic/private';
export const GET: RequestHandler = async ({ params }) => {
const url = `${env.BASE_URL ?? 'http://localhost:5173'}/devices/${params.id}`;
const svg = await generateQrSvg(url);
return new Response(svg, {
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=86400'
}
});
};
@@ -0,0 +1,92 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices, computerDetails, locations, deviceLog } from '$lib/server/db/schema.js';
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
const deviceSchema = z.object({
title: z.string().min(1, 'Title is required'),
category: z.enum(['Computer', 'Audio Equipment', 'Peripheral', 'Other']),
brand: z.string().optional(),
model: z.string().optional(),
serialNumber: z.string().optional(),
year: z.coerce.number().int().min(1900).max(2100).optional().or(z.literal('')),
condition: z.enum(['Working', 'In Repair', 'Waiting for Repair', 'Waiting to be Tested', 'Unrepairable']),
voltage: z.string().optional(),
frequency: z.string().optional(),
origin: z.string().optional(),
initialCondition: z.string().optional(),
faultDescription: z.string().optional(),
repairNotes: z.string().optional(),
locationId: z.string().uuid().optional().or(z.literal('')),
generalNotes: z.string().optional(),
// Computer-specific
osVersion: z.string().optional(),
firmwareVersion: z.string().optional(),
installedSoftware: z.string().optional()
});
export const load: PageServerLoad = async () => {
const locationList = await db.select({ id: locations.id, name: locations.name }).from(locations);
return { locations: locationList };
};
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
const result = deviceSchema.safeParse(raw);
if (!result.success) {
return fail(400, { error: result.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const data = result.data;
const year = typeof data.year === 'number' ? data.year : null;
const locationId = data.locationId || null;
const [device] = await db
.insert(devices)
.values({
title: data.title,
category: data.category,
brand: data.brand || null,
model: data.model || null,
serialNumber: data.serialNumber || null,
year,
condition: data.condition,
voltage: data.voltage || null,
frequency: data.frequency || null,
origin: data.origin || null,
initialCondition: data.initialCondition || null,
faultDescription: data.faultDescription || null,
repairNotes: data.repairNotes || null,
locationId,
generalNotes: data.generalNotes || null
})
.returning({ id: devices.id });
// If computer, insert computer details
if (data.category === 'Computer' && device) {
await db.insert(computerDetails).values({
deviceId: device.id,
osVersion: data.osVersion || null,
firmwareVersion: data.firmwareVersion || null,
installedSoftware: data.installedSoftware || null
});
}
// Create initial log entry if initial condition was provided
if (data.initialCondition && device) {
await db.insert(deviceLog).values({
deviceId: device.id,
type: 'inspection',
description: `Intake: ${data.initialCondition}`,
conditionAfter: data.condition,
performedBy: null
});
}
redirect(303, `/devices/${device!.id}`);
}
};
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { DEVICE_CATEGORIES, DEVICE_CONDITIONS, VOLTAGE_OPTIONS, FREQUENCY_OPTIONS } from '$lib/constants.js';
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
let { data, form } = $props();
let category = $state(form?.values?.category ?? 'Computer');
let brand = $state(String(form?.values?.brand ?? ''));
let model = $state(String(form?.values?.model ?? ''));
</script>
<svelte:head>
<title>Add Device - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Add Device</h1>
{#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}
<form method="POST" use:enhance class="space-y-6">
<!-- Identity -->
<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">Identity</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. Color Classic #5" />
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="category" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Category *</label>
<select id="category" name="category" bind:value={category}
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 DEVICE_CATEGORIES as cat}
<option value={cat}>{cat}</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 DEVICE_CONDITIONS as cond}
<option value={cond} selected={cond === (form?.values?.condition ?? 'Waiting to be Tested')}>{cond}</option>
{/each}
</select>
</div>
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<div>
<label for="brand" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Brand</label>
<AutocompleteInput id="brand" name="brand" bind:value={brand} placeholder="e.g. Apple"
fetchUrl="/api/devices/brands" />
</div>
<div>
<label for="model" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
<AutocompleteInput id="model" name="model" bind:value={model} placeholder="e.g. Color Classic"
fetchUrl="/api/devices/models" extraParams={brand ? { brand: String(brand) } : {}} />
</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 dark:placeholder-gray-400" />
</div>
</div>
</div>
<!-- Technical Specs -->
<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">Technical Specs</h2>
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<div>
<label for="year" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Year</label>
<input type="number" id="year" name="year" value={form?.values?.year ?? ''} min="1900" max="2100"
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" />
</div>
<div>
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage</label>
<select id="voltage" name="voltage"
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=""></option>
{#each VOLTAGE_OPTIONS as v}
<option value={v} selected={form?.values?.voltage === v}>{v}</option>
{/each}
</select>
</div>
<div>
<label for="frequency" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Frequency</label>
<select id="frequency" name="frequency"
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=""></option>
{#each FREQUENCY_OPTIONS as f}
<option value={f} selected={form?.values?.frequency === f}>{f}</option>
{/each}
</select>
</div>
</div>
<div class="mb-4">
<label for="origin" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Origin</label>
<input type="text" id="origin" name="origin" value={form?.values?.origin ?? ''}
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. Japan" />
</div>
</div>
<!-- Computer Details (conditional) -->
{#if category === 'Computer'}
<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">Computer Details</h2>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="osVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">OS Version</label>
<input type="text" id="osVersion" name="osVersion" value={form?.values?.osVersion ?? ''}
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. System 7.1" />
</div>
<div>
<label for="firmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware / ROM 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 dark:placeholder-gray-400" />
</div>
</div>
<div>
<label for="installedSoftware" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Installed Software</label>
<textarea id="installedSoftware" name="installedSoftware" rows="3"
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="Notable software installed...">{form?.values?.installedSoftware ?? ''}</textarea>
</div>
</div>
{/if}
<!-- Initial Condition -->
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-5 dark:border-blue-800 dark:bg-blue-900/10">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-blue-500 dark:text-blue-400">Condition on Intake</h2>
<div>
<label for="initialCondition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Describe the condition when entering the system</label>
<textarea id="initialCondition" name="initialCondition" rows="4"
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. Cosmetically good, screen has slight burn-in, powers on but no chime, missing PRAM battery, HDD clicks on spin-up...">{form?.values?.initialCondition ?? ''}</textarea>
</div>
</div>
<!-- Condition & Repair -->
<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">Condition & Repair</h2>
<div class="mb-4">
<label for="faultDescription" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Fault Description</label>
<textarea id="faultDescription" name="faultDescription" rows="3"
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="Describe any known faults...">{form?.values?.faultDescription ?? ''}</textarea>
</div>
<div>
<label for="repairNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Repair Notes</label>
<textarea id="repairNotes" name="repairNotes" rows="3"
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="Repair history and notes...">{form?.values?.repairNotes ?? ''}</textarea>
</div>
</div>
<!-- Location & Notes -->
<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 & Notes</h2>
<div class="mb-4">
<label for="locationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
<select id="locationId" name="locationId"
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="">No location</option>
{#each data.locations as loc}
<option value={loc.id} selected={form?.values?.locationId === loc.id}>{loc.name}</option>
{/each}
</select>
</div>
<div>
<label for="generalNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">General Notes</label>
<textarea id="generalNotes" name="generalNotes" rows="3"
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">{form?.values?.generalNotes ?? ''}</textarea>
</div>
</div>
<!-- Submit -->
<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">
Create Device
</button>
<a href="/devices" class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
Cancel
</a>
</div>
</form>
</div>