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:
@@ -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 }
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
· {[data.device.brand, data.device.model].filter(Boolean).join(' ')}
|
||||
{/if}
|
||||
{#if data.device.year}
|
||||
· {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}· {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}· {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">← 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}`);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user