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
+3
View File
@@ -0,0 +1,3 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="@sveltejs/kit" />
declare global {
namespace App {
interface Locals {}
interface PageData {}
interface Platform {}
}
}
export {};
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function() {
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { page } from '$app/stores';
interface Props {
open: boolean;
onToggle: () => void;
counts: { devices: number; components: number; needsRepair: number };
}
let { open, onToggle, counts }: Props = $props();
const navItems = [
{
href: '/',
label: 'Dashboard',
icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'
},
{
href: '/devices',
label: 'Devices',
badge: counts.devices,
icon: 'M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'
},
{
href: '/components',
label: 'Components',
badge: counts.components,
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z'
},
{
href: '/installations',
label: 'Install Log',
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01'
},
{
href: '/locations',
label: 'Locations',
icon: 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z'
},
{
href: '/gallery',
label: 'Gallery',
icon: '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'
}
];
</script>
<aside
class="flex w-64 flex-col border-r border-gray-200 bg-white transition-transform duration-200 dark:border-gray-700 dark:bg-gray-800 {open
? 'translate-x-0'
: '-translate-x-full'} fixed inset-y-0 left-0 z-30 lg:relative lg:translate-x-0"
>
<!-- Logo -->
<div class="flex h-14 items-center border-b border-gray-200 px-4 dark:border-gray-700">
<a href="/" class="text-lg font-bold text-gray-900 dark:text-white">B4L Repair</a>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 py-4">
{#each navItems as item}
<a
href={item.href}
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
</svg>
{item.label}
{#if item.badge}
<span class="ml-auto rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{item.badge}
</span>
{/if}
</a>
{/each}
{#if counts.needsRepair > 0}
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Attention
</div>
<a
href="/devices?condition=needs-repair"
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-900/20"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
Needs Repair
<span class="ml-auto rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
{counts.needsRepair}
</span>
</a>
{/if}
</nav>
</aside>
<!-- Backdrop for mobile -->
{#if open}
<button
class="fixed inset-0 z-20 bg-black/30 lg:hidden"
onclick={onToggle}
aria-label="Close sidebar"
></button>
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
</script>
<button
onclick={() => theme.toggle()}
class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
title="Toggle {theme.isDark ? 'light' : 'dark'} mode"
>
{#if theme.isDark}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{:else}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{/if}
</button>
@@ -0,0 +1,101 @@
<script lang="ts">
interface Props {
id: string;
name: string;
value: string;
placeholder?: string;
fetchUrl: string;
extraParams?: Record<string, string>;
}
let { id, name, value = $bindable(), placeholder = '', fetchUrl, extraParams = {} }: Props = $props();
let suggestions = $state<string[]>([]);
let showDropdown = $state(false);
let activeIndex = $state(-1);
let debounceTimer: ReturnType<typeof setTimeout>;
function fetchSuggestions(query: string) {
clearTimeout(debounceTimer);
if (!query || query.length < 1) {
suggestions = [];
showDropdown = false;
return;
}
debounceTimer = setTimeout(async () => {
const params = new URLSearchParams({ q: query, ...extraParams });
const res = await fetch(`${fetchUrl}?${params}`);
if (res.ok) {
suggestions = await res.json();
showDropdown = suggestions.length > 0;
activeIndex = -1;
}
}, 200);
}
function handleInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
value = val;
fetchSuggestions(val);
}
function select(suggestion: string) {
value = suggestion;
showDropdown = false;
suggestions = [];
}
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, suggestions.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, -1);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
select(suggestions[activeIndex]);
} else if (e.key === 'Escape') {
showDropdown = false;
}
}
function handleBlur() {
// Delay to allow click on suggestion
setTimeout(() => { showDropdown = false; }, 150);
}
</script>
<div class="relative">
<input
type="text"
{id}
{name}
{value}
{placeholder}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={() => { if (suggestions.length > 0) showDropdown = true; }}
onblur={handleBlur}
autocomplete="off"
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"
/>
{#if showDropdown}
<ul class="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-gray-200 bg-white shadow-md dark:border-gray-600 dark:bg-gray-700">
{#each suggestions as suggestion, i}
<li>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600
{i === activeIndex ? 'bg-gray-100 dark:bg-gray-600' : ''}"
onmousedown={() => select(suggestion)}
>
{suggestion}
</button>
</li>
{/each}
</ul>
{/if}
</div>
+60
View File
@@ -0,0 +1,60 @@
export const DEVICE_CATEGORIES = ['Computer', 'Audio Equipment', 'Peripheral', 'Other'] as const;
export const DEVICE_CONDITIONS = [
'Working',
'In Repair',
'Waiting for Repair',
'Waiting to be Tested',
'Unrepairable'
] as const;
export const COMPONENT_CONDITIONS = ['Working', 'Faulty', 'Unknown', 'Refurbished'] as const;
export const COMPONENT_TYPES = [
'Logic Board',
'RAM',
'HDD',
'SSD',
'CPU',
'GPU',
'PSU',
'ROM',
'BlueSCSI',
'SCSI2SD',
'Network Card',
'Sound Card',
'Video Card',
'Floppy Drive',
'CD/DVD Drive',
'Belt',
'Head',
'Motor',
'Capacitor Kit',
'Battery',
'Cable/Adapter',
'Other'
] as const;
export const INSTALLATION_ACTIONS = ['installed', 'removed', 'swapped'] as const;
export const DEVICE_LOG_TYPES = [
'repair',
'inspection',
'cleaning',
'modification',
'diagnostic',
'recap',
'other'
] as const;
export type DeviceLogType = (typeof DEVICE_LOG_TYPES)[number];
export const VOLTAGE_OPTIONS = ['110V', '115V', '120V', '127V', '220V', '230V', '240V', 'DC powered'] as const;
export const FREQUENCY_OPTIONS = ['50Hz', '60Hz', '50/60Hz'] as const;
export type DeviceCategory = (typeof DEVICE_CATEGORIES)[number];
export type DeviceCondition = (typeof DEVICE_CONDITIONS)[number];
export type ComponentCondition = (typeof COMPONENT_CONDITIONS)[number];
export type ComponentType = (typeof COMPONENT_TYPES)[number];
export type InstallationAction = (typeof INSTALLATION_ACTIONS)[number];
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
import { env } from '$env/dynamic/private';
const pool = new pg.Pool({
connectionString: env.DATABASE_URL
});
export const db = drizzle(pool, { schema });
export type Database = typeof db;
+199
View File
@@ -0,0 +1,199 @@
import {
pgTable,
uuid,
text,
integer,
boolean,
timestamp,
index,
check
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
// ─── Locations ──────────────────────────────────────────────────────
export const locations = pgTable('locations', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
description: text('description'),
parentId: uuid('parent_id').references((): any => locations.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
// ─── Devices ────────────────────────────────────────────────────────
export const devices = pgTable(
'devices',
{
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
category: text('category').notNull(),
brand: text('brand'),
model: text('model'),
serialNumber: text('serial_number'),
year: integer('year'),
condition: text('condition').notNull().default('Waiting to be Tested'),
voltage: text('voltage'),
frequency: text('frequency'),
origin: text('origin'),
faultDescription: text('fault_description'),
repairNotes: text('repair_notes'),
locationId: uuid('location_id').references(() => locations.id),
initialCondition: text('initial_condition'),
generalNotes: text('general_notes'),
disabled: boolean('disabled').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('devices_category_idx').on(table.category),
index('devices_condition_idx').on(table.condition),
index('devices_location_idx').on(table.locationId),
check('devices_category_check', sql`${table.category} IN ('Computer', 'Audio Equipment', 'Peripheral', 'Other')`),
check('devices_condition_check', sql`${table.condition} IN ('Working', 'In Repair', 'Waiting for Repair', 'Waiting to be Tested', 'Unrepairable')`)
]
);
// ─── Computer Details (1:1 extension) ───────────────────────────────
export const computerDetails = pgTable('computer_details', {
id: uuid('id').defaultRandom().primaryKey(),
deviceId: uuid('device_id')
.notNull()
.unique()
.references(() => devices.id, { onDelete: 'cascade' }),
osVersion: text('os_version'),
firmwareVersion: text('firmware_version'),
installedSoftware: text('installed_software')
});
// ─── Device Images ──────────────────────────────────────────────────
export const deviceImages = pgTable(
'device_images',
{
id: uuid('id').defaultRandom().primaryKey(),
deviceId: uuid('device_id')
.notNull()
.references(() => devices.id, { onDelete: 'cascade' }),
filePath: text('file_path').notNull(),
thumbnailPath: text('thumbnail_path'),
caption: text('caption'),
sortOrder: integer('sort_order').default(0),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [index('device_images_device_idx').on(table.deviceId)]
);
// ─── Device Documents ───────────────────────────────────────────────
export const deviceDocuments = pgTable(
'device_documents',
{
id: uuid('id').defaultRandom().primaryKey(),
deviceId: uuid('device_id')
.notNull()
.references(() => devices.id, { onDelete: 'cascade' }),
filePath: text('file_path').notNull(),
originalFilename: text('original_filename').notNull(),
fileType: text('file_type'),
description: text('description'),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [index('device_documents_device_idx').on(table.deviceId)]
);
// ─── Components ─────────────────────────────────────────────────────
export const components = pgTable(
'components',
{
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
componentType: text('component_type').notNull(),
brand: text('brand'),
partNumber: text('part_number'),
serialNumber: text('serial_number'),
condition: text('condition').notNull().default('Working'),
firmwareVersion: text('firmware_version'),
specs: text('specs'),
notes: text('notes'),
currentDeviceId: uuid('current_device_id').references(() => devices.id, {
onDelete: 'set null'
}),
locationId: uuid('location_id').references(() => locations.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('components_type_idx').on(table.componentType),
index('components_device_idx').on(table.currentDeviceId),
index('components_location_idx').on(table.locationId),
check('components_condition_check', sql`${table.condition} IN ('Working', 'Faulty', 'Unknown', 'Refurbished')`)
]
);
// ─── Component Images ───────────────────────────────────────────────
export const componentImages = pgTable(
'component_images',
{
id: uuid('id').defaultRandom().primaryKey(),
componentId: uuid('component_id')
.notNull()
.references(() => components.id, { onDelete: 'cascade' }),
filePath: text('file_path').notNull(),
thumbnailPath: text('thumbnail_path'),
caption: text('caption'),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [index('component_images_component_idx').on(table.componentId)]
);
// ─── Installation Log (append-only) ─────────────────────────────────
export const installationLog = pgTable(
'installation_log',
{
id: uuid('id').defaultRandom().primaryKey(),
componentId: uuid('component_id')
.notNull()
.references(() => components.id, { onDelete: 'cascade' }),
deviceId: uuid('device_id')
.notNull()
.references(() => devices.id, { onDelete: 'cascade' }),
action: text('action').notNull(),
performedBy: text('performed_by'),
notes: text('notes'),
performedAt: timestamp('performed_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('install_log_component_idx').on(table.componentId),
index('install_log_device_idx').on(table.deviceId),
index('install_log_date_idx').on(table.performedAt),
check('install_log_action_check', sql`${table.action} IN ('installed', 'removed', 'swapped')`)
]
);
// ─── Device Log (append-only repair/operation history) ───────────────
export const deviceLog = pgTable(
'device_log',
{
id: uuid('id').defaultRandom().primaryKey(),
deviceId: uuid('device_id')
.notNull()
.references(() => devices.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
description: text('description').notNull(),
conditionAfter: text('condition_after'),
performedBy: text('performed_by'),
performedAt: timestamp('performed_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('device_log_device_idx').on(table.deviceId),
index('device_log_date_idx').on(table.performedAt),
check('device_log_type_check', sql`${table.type} IN ('repair', 'inspection', 'cleaning', 'modification', 'diagnostic', 'recap', 'other')`)
]
);
+9
View File
@@ -0,0 +1,9 @@
import QRCode from 'qrcode';
export async function generateQrSvg(url: string): Promise<string> {
return QRCode.toString(url, { type: 'svg', margin: 1 });
}
export async function generateQrDataUrl(url: string): Promise<string> {
return QRCode.toDataURL(url, { margin: 1, width: 256 });
}
+73
View File
@@ -0,0 +1,73 @@
import { randomUUID } from 'crypto';
import { writeFile, unlink, mkdir } from 'fs/promises';
import { join, extname } from 'path';
import sharp from 'sharp';
const UPLOAD_BASE = 'static/uploads';
const THUMBNAIL_WIDTH = 300;
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
const ALLOWED_DOC_TYPES = ['application/pdf', 'text/plain', 'application/zip'];
export async function saveImage(
file: File,
subfolder: 'devices' | 'components'
): Promise<{ filePath: string; thumbnailPath: string }> {
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
throw new Error(`Invalid image type: ${file.type}`);
}
const ext = extname(file.name) || '.jpg';
const filename = `${randomUUID()}${ext}`;
const thumbFilename = `thumb_${filename}`;
const dir = join(UPLOAD_BASE, subfolder);
await mkdir(dir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
// Save original
const filePath = join(dir, filename);
await writeFile(filePath, buffer);
// Generate thumbnail
const thumbnailPath = join(dir, thumbFilename);
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: 80 }).toFile(thumbnailPath);
// Return paths relative to static/ for serving
return {
filePath: `/uploads/${subfolder}/${filename}`,
thumbnailPath: `/uploads/${subfolder}/${thumbFilename}`
};
}
export async function saveDocument(file: File): Promise<{ filePath: string; originalFilename: string }> {
if (![...ALLOWED_IMAGE_TYPES, ...ALLOWED_DOC_TYPES].includes(file.type)) {
throw new Error(`Invalid file type: ${file.type}`);
}
const ext = extname(file.name) || '';
const filename = `${randomUUID()}${ext}`;
const dir = join(UPLOAD_BASE, 'documents');
await mkdir(dir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = join(dir, filename);
await writeFile(filePath, buffer);
return {
filePath: `/uploads/documents/${filename}`,
originalFilename: file.name
};
}
export async function deleteFile(filePath: string): Promise<void> {
try {
// filePath is like /uploads/devices/xxx.jpg, need to prepend static/
const fullPath = join('static', filePath);
await unlink(fullPath);
} catch {
// File may already be deleted
}
}
+39
View File
@@ -0,0 +1,39 @@
import { browser } from '$app/environment';
type Theme = 'light' | 'dark';
function getInitialTheme(): Theme {
if (!browser) return 'light';
const stored = localStorage.getItem('theme');
if (stored === 'dark' || stored === 'light') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
let current = $state<Theme>(getInitialTheme());
export const theme = {
get value() {
return current;
},
get isDark() {
return current === 'dark';
},
toggle() {
current = current === 'dark' ? 'light' : 'dark';
apply();
},
set(t: Theme) {
current = t;
apply();
}
};
function apply() {
if (!browser) return;
document.documentElement.classList.toggle('dark', current === 'dark');
localStorage.setItem('theme', current);
}
if (browser) {
apply();
}
+19
View File
@@ -0,0 +1,19 @@
import { format, formatDistanceToNow } from 'date-fns';
export function formatDate(date: Date | string | null): string {
if (!date) return '—';
const d = typeof date === 'string' ? new Date(date) : date;
return format(d, 'yyyy-MM-dd');
}
export function formatDateTime(date: Date | string | null): string {
if (!date) return '—';
const d = typeof date === 'string' ? new Date(date) : date;
return format(d, 'yyyy-MM-dd HH:mm');
}
export function timeAgo(date: Date | string | null): string {
if (!date) return '—';
const d = typeof date === 'string' ? new Date(date) : date;
return formatDistanceToNow(d, { addSuffix: true });
}
+21
View File
@@ -0,0 +1,21 @@
import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices, components } from '$lib/server/db/schema.js';
import { count, eq, or, and } from 'drizzle-orm';
export const load: LayoutServerLoad = async () => {
const [deviceCount] = await db.select({ value: count() }).from(devices).where(eq(devices.disabled, false));
const [componentCount] = await db.select({ value: count() }).from(components);
const [repairCount] = await db
.select({ value: count() })
.from(devices)
.where(and(eq(devices.disabled, false), or(eq(devices.condition, 'In Repair'), eq(devices.condition, 'Waiting for Repair'))));
return {
counts: {
devices: deviceCount?.value ?? 0,
components: componentCount?.value ?? 0,
needsRepair: repairCount?.value ?? 0
}
};
};
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import ThemeToggle from '$lib/components/layout/ThemeToggle.svelte';
let { data, children } = $props();
let sidebarOpen = $state(true);
</script>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900">
<Sidebar
counts={data.counts}
open={sidebarOpen}
onToggle={() => (sidebarOpen = !sidebarOpen)}
/>
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Top bar -->
<header class="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-800">
<button
onclick={() => (sidebarOpen = !sidebarOpen)}
class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 lg:hidden dark:text-gray-400 dark:hover:bg-gray-700"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<form action="/lookup" method="GET" class="ml-4 flex-1 lg:max-w-sm">
<div class="relative">
<svg class="absolute left-2.5 top-2.5 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input type="text" name="q" placeholder="Search or scan ID..."
class="w-full rounded-md border border-gray-300 py-1.5 pl-9 pr-3 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>
</form>
<div class="ml-auto flex items-center gap-3">
<ThemeToggle />
</div>
</header>
<!-- Main content -->
<main class="flex-1 overflow-y-auto p-6">
{@render children()}
</main>
</div>
</div>
+71
View File
@@ -0,0 +1,71 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices, components, installationLog } from '$lib/server/db/schema.js';
import { count, eq, or, and, desc, sql } from 'drizzle-orm';
export const load: PageServerLoad = async () => {
const active = eq(devices.disabled, false);
// Total counts
const [totalDevices] = await db.select({ value: count() }).from(devices).where(active);
const [computers] = await db
.select({ value: count() })
.from(devices)
.where(and(active, eq(devices.category, 'Computer')));
const [audio] = await db
.select({ value: count() })
.from(devices)
.where(and(active, eq(devices.category, 'Audio Equipment')));
const [totalComponents] = await db.select({ value: count() }).from(components);
// Condition breakdown
const conditionBreakdown = await db
.select({
condition: devices.condition,
count: count()
})
.from(devices)
.where(active)
.groupBy(devices.condition);
// Recent installation activity
const recentActivity = await db
.select({
action: installationLog.action,
performedAt: installationLog.performedAt,
componentId: installationLog.componentId,
deviceId: installationLog.deviceId,
componentTitle: components.title,
deviceTitle: devices.title
})
.from(installationLog)
.innerJoin(components, eq(installationLog.componentId, components.id))
.innerJoin(devices, eq(installationLog.deviceId, devices.id))
.orderBy(desc(installationLog.performedAt))
.limit(10);
// Devices needing repair
const needsRepair = await db
.select({
id: devices.id,
title: devices.title,
category: devices.category,
condition: devices.condition,
faultDescription: devices.faultDescription
})
.from(devices)
.where(and(active, or(eq(devices.condition, 'In Repair'), eq(devices.condition, 'Waiting for Repair'))))
.limit(10);
return {
stats: {
totalDevices: totalDevices?.value ?? 0,
computers: computers?.value ?? 0,
audio: audio?.value ?? 0,
totalComponents: totalComponents?.value ?? 0
},
conditionBreakdown,
recentActivity,
needsRepair
};
};
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { timeAgo } from '$lib/utils/date.js';
let { data } = $props();
</script>
<svelte:head>
<title>Dashboard - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<!-- Stats Cards -->
<div class="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<a href="/devices" class="rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div class="mb-1 text-sm font-medium text-gray-500 dark:text-gray-400">Total Devices</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white">{data.stats.totalDevices}</div>
</a>
<a href="/devices?category=Computer" class="rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div class="mb-1 text-sm font-medium text-gray-500 dark:text-gray-400">Computers</div>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">{data.stats.computers}</div>
</a>
<a href="/devices?category=Audio+Equipment" class="rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div class="mb-1 text-sm font-medium text-gray-500 dark:text-gray-400">Audio Equipment</div>
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400">{data.stats.audio}</div>
</a>
<a href="/components" class="rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div class="mb-1 text-sm font-medium text-gray-500 dark:text-gray-400">Components</div>
<div class="text-3xl font-bold text-gray-900 dark:text-white">{data.stats.totalComponents}</div>
</a>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- Condition Breakdown -->
<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">Device Conditions</h2>
<div class="space-y-2">
{#each data.conditionBreakdown as item}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">{item.condition}</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{item.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{item.condition === 'In Repair' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : ''}
{item.condition === 'Waiting for Repair' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' : ''}
{item.condition === 'Waiting to be Tested' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
{item.condition === 'Unrepairable' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
">
{item.count}
</span>
</div>
{/each}
</div>
</div>
<!-- Recent Activity -->
<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">Recent Activity</h2>
{#if data.recentActivity.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No installation activity yet.</p>
{:else}
<div class="space-y-3">
{#each data.recentActivity as entry}
<div class="flex items-start gap-3">
<span class="mt-0.5 rounded-full px-2 py-0.5 text-xs font-medium
{entry.action === 'installed' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{entry.action === 'removed' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
{entry.action === 'swapped' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
">
{entry.action}
</span>
<div class="flex-1">
<p class="text-sm text-gray-700 dark:text-gray-300">
<a href="/components/{entry.componentId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{entry.componentTitle}</a>
{entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'}
<a href="/devices/{entry.deviceId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{entry.deviceTitle}</a>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{timeAgo(entry.performedAt)}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Needs Repair -->
{#if data.needsRepair.length > 0}
<div class="mt-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">Needs Attention</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-700">
<th class="py-2 pr-4 text-left font-medium text-gray-500 dark:text-gray-400">Device</th>
<th class="py-2 pr-4 text-left font-medium text-gray-500 dark:text-gray-400">Category</th>
<th class="py-2 pr-4 text-left font-medium text-gray-500 dark:text-gray-400">Condition</th>
<th class="py-2 text-left font-medium text-gray-500 dark:text-gray-400">Fault</th>
</tr>
</thead>
<tbody>
{#each data.needsRepair as device}
<tr class="border-b border-gray-100 last:border-0 dark:border-gray-700">
<td class="py-2 pr-4">
<a href="/devices/{device.id}" class="font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{device.title}
</a>
</td>
<td class="py-2 pr-4 text-gray-600 dark:text-gray-400">{device.category}</td>
<td class="py-2 pr-4">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{device.condition === 'In Repair' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300'}
">
{device.condition}
</span>
</td>
<td class="py-2 text-gray-600 dark:text-gray-400">{device.faultDescription ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,62 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations } from '$lib/server/db/schema.js';
import { eq, ilike, or, and, isNull, isNotNull, count, desc } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url }) => {
const type = url.searchParams.get('type');
const condition = url.searchParams.get('condition');
const status = url.searchParams.get('status'); // 'installed' | 'storage'
const search = url.searchParams.get('q');
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1));
const pageSize = 24;
const conditions = [];
if (type) conditions.push(eq(components.componentType, type));
if (condition) conditions.push(eq(components.condition, condition));
if (status === 'installed') conditions.push(isNotNull(components.currentDeviceId));
if (status === 'storage') conditions.push(isNull(components.currentDeviceId));
if (search) {
conditions.push(
or(
ilike(components.title, `%${search}%`),
ilike(components.brand, `%${search}%`),
ilike(components.partNumber, `%${search}%`),
ilike(components.serialNumber, `%${search}%`)
)!
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db.select({ value: count() }).from(components).where(where);
const componentList = await db
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
brand: components.brand,
partNumber: components.partNumber,
condition: components.condition,
currentDeviceId: components.currentDeviceId,
deviceTitle: devices.title,
locationName: locations.name
})
.from(components)
.leftJoin(devices, eq(components.currentDeviceId, devices.id))
.leftJoin(locations, eq(components.locationId, locations.id))
.where(where)
.orderBy(desc(components.updatedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return {
components: componentList,
total: totalResult?.value ?? 0,
page,
pageSize,
filters: { type, condition, status, search }
};
};
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { COMPONENT_TYPES, COMPONENT_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>Components - 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">Components</h1>
<a href="/components/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Add Component
</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 components..."
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('type', (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 Types</option>
{#each COMPONENT_TYPES as t}
<option value={t} selected={data.filters.type === t}>{t}</option>
{/each}
</select>
<select onchange={(e) => applyFilter('condition', (e.target as HTMLSelectElement).value || null)}
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Conditions</option>
{#each COMPONENT_CONDITIONS as c}
<option value={c} selected={data.filters.condition === c}>{c}</option>
{/each}
</select>
<select onchange={(e) => applyFilter('status', (e.target as HTMLSelectElement).value || null)}
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All</option>
<option value="installed" selected={data.filters.status === 'installed'}>Installed</option>
<option value="storage" selected={data.filters.status === 'storage'}>In Storage</option>
</select>
</div>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">{data.total} component{data.total !== 1 ? 's' : ''}</p>
{#if data.components.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No components found.</p>
<a href="/components/new" class="mt-2 inline-block text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">Add your first component</a>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Component</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Type</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Condition</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Location</th>
</tr>
</thead>
<tbody>
{#each data.components as comp}
<tr class="border-b border-gray-100 last:border-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/30">
<td class="px-4 py-3">
<a href="/components/{comp.id}" class="font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{comp.title}
</a>
{#if comp.brand}
<span class="ml-1 text-gray-400 dark:text-gray-500">{comp.brand}</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{comp.componentType}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{comp.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{comp.condition === 'Faulty' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
{comp.condition === 'Unknown' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : ''}
{comp.condition === 'Refurbished' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
">
{comp.condition}
</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{#if comp.deviceTitle}
<a href="/devices/{comp.currentDeviceId}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">
{comp.deviceTitle}
</a>
{:else if comp.locationName}
{comp.locationName}
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
{#if data.page > 1}
<a href="?page={data.page - 1}" 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="?page={data.page + 1}" 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,55 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations, installationLog, componentImages } from '$lib/server/db/schema.js';
import { eq, desc } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
const [component] = await db
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
brand: components.brand,
partNumber: components.partNumber,
serialNumber: components.serialNumber,
condition: components.condition,
firmwareVersion: components.firmwareVersion,
specs: components.specs,
notes: components.notes,
currentDeviceId: components.currentDeviceId,
deviceTitle: devices.title,
locationId: components.locationId,
locationName: locations.name,
createdAt: components.createdAt,
updatedAt: components.updatedAt
})
.from(components)
.leftJoin(devices, eq(components.currentDeviceId, devices.id))
.leftJoin(locations, eq(components.locationId, locations.id))
.where(eq(components.id, params.id));
if (!component) error(404, 'Component not found');
const images = await db
.select()
.from(componentImages)
.where(eq(componentImages.componentId, params.id));
const history = await db
.select({
id: installationLog.id,
action: installationLog.action,
performedAt: installationLog.performedAt,
performedBy: installationLog.performedBy,
notes: installationLog.notes,
deviceId: installationLog.deviceId,
deviceTitle: devices.title
})
.from(installationLog)
.innerJoin(devices, eq(installationLog.deviceId, devices.id))
.where(eq(installationLog.componentId, params.id))
.orderBy(desc(installationLog.performedAt));
return { component, images, history };
};
@@ -0,0 +1,149 @@
<script lang="ts">
import { formatDate, timeAgo } from '$lib/utils/date.js';
let { data } = $props();
const c = data.component;
</script>
<svelte:head>
<title>{c.title} - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<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">{c.title}</h1>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{c.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{c.condition === 'Faulty' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
{c.condition === 'Unknown' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : ''}
{c.condition === 'Refurbished' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
">
{c.condition}
</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
{c.componentType}
{#if c.brand}&middot; {c.brand}{/if}
</p>
</div>
<div class="flex gap-2">
<a href="/components/{c.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="/components/{c.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>
{#if c.currentDeviceId}
<a href="/installations/new?componentId={c.id}&action=removed"
class="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Remove from Device
</a>
{:else}
<a href="/installations/new?componentId={c.id}"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Install into Device
</a>
{/if}
</div>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<!-- Current Location -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Current Location</h2>
{#if c.currentDeviceId}
<p class="text-sm text-gray-700 dark:text-gray-300">
Installed in
<a href="/devices/{c.currentDeviceId}" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">{c.deviceTitle}</a>
</p>
{:else if c.locationName}
<p class="text-sm text-gray-700 dark:text-gray-300">In storage at <span class="font-medium">{c.locationName}</span></p>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">In storage (no location set)</p>
{/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>
{entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'}
<a href="/devices/{entry.deviceId}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{entry.deviceTitle}</a>
</p>
{#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>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-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 c.partNumber}
<div>
<dt class="text-gray-500 dark:text-gray-400">Part Number</dt>
<dd class="font-mono text-gray-900 dark:text-white">{c.partNumber}</dd>
</div>
{/if}
{#if c.serialNumber}
<div>
<dt class="text-gray-500 dark:text-gray-400">Serial Number</dt>
<dd class="font-mono text-gray-900 dark:text-white">{c.serialNumber}</dd>
</div>
{/if}
{#if c.firmwareVersion}
<div>
<dt class="text-gray-500 dark:text-gray-400">Firmware</dt>
<dd class="text-gray-900 dark:text-white">{c.firmwareVersion}</dd>
</div>
{/if}
{#if c.specs}
<div>
<dt class="text-gray-500 dark:text-gray-400">Specs</dt>
<dd class="whitespace-pre-wrap text-gray-900 dark:text-white">{c.specs}</dd>
</div>
{/if}
<div>
<dt class="text-gray-500 dark:text-gray-400">Added</dt>
<dd class="text-gray-900 dark:text-white">{formatDate(c.createdAt)}</dd>
</div>
</dl>
</div>
{#if c.notes}
<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">{c.notes}</p>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,61 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
const componentSchema = z.object({
title: z.string().min(1, 'Title is required'),
componentType: z.string().min(1),
brand: z.string().optional(),
partNumber: z.string().optional(),
serialNumber: z.string().optional(),
condition: z.enum(['Working', 'Faulty', 'Unknown', 'Refurbished']),
firmwareVersion: z.string().optional(),
specs: z.string().optional(),
notes: z.string().optional(),
locationId: z.string().uuid().optional().or(z.literal(''))
});
export const load: PageServerLoad = async ({ params }) => {
const [component] = await db.select().from(components).where(eq(components.id, params.id));
if (!component) error(404, 'Component not found');
const locationList = await db.select({ id: locations.id, name: locations.name }).from(locations);
return { component, locations: locationList };
};
export const actions: Actions = {
default: async ({ request, params }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
const result = componentSchema.safeParse(raw);
if (!result.success) {
return fail(400, { error: result.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const data = result.data;
await db
.update(components)
.set({
title: data.title,
componentType: data.componentType,
brand: data.brand || null,
partNumber: data.partNumber || null,
serialNumber: data.serialNumber || null,
condition: data.condition,
firmwareVersion: data.firmwareVersion || null,
specs: data.specs || null,
notes: data.notes || null,
locationId: data.locationId || null,
updatedAt: new Date()
})
.where(eq(components.id, params.id));
redirect(303, `/components/${params.id}`);
}
};
@@ -0,0 +1,106 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { COMPONENT_TYPES, COMPONENT_CONDITIONS } from '$lib/constants.js';
let { data, form } = $props();
const c = data.component;
</script>
<svelte:head>
<title>Edit {c.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 {c.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">
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Component Info</h2>
<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 ?? c.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="componentType" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Type *</label>
<select id="componentType" name="componentType" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_TYPES as t}
<option value={t} selected={t === (form?.values?.componentType ?? c.componentType)}>{t}</option>
{/each}
</select>
</div>
<div>
<label for="condition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition *</label>
<select id="condition" name="condition"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_CONDITIONS as cnd}
<option value={cnd} selected={cnd === (form?.values?.condition ?? c.condition)}>{cnd}</option>
{/each}
</select>
</div>
</div>
<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>
<input type="text" id="brand" name="brand" value={form?.values?.brand ?? c.brand ?? ''}
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="partNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Part Number</label>
<input type="text" id="partNumber" name="partNumber" value={form?.values?.partNumber ?? c.partNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<label for="serialNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Serial Number</label>
<input type="text" id="serialNumber" name="serialNumber" value={form?.values?.serialNumber ?? c.serialNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
<div class="mb-4">
<label for="firmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware Version</label>
<input type="text" id="firmwareVersion" name="firmwareVersion" value={form?.values?.firmwareVersion ?? c.firmwareVersion ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div class="mb-4">
<label for="specs" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Specs</label>
<textarea id="specs" name="specs" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{form?.values?.specs ?? c.specs ?? ''}</textarea>
</div>
<div>
<label for="notes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<textarea id="notes" name="notes" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{form?.values?.notes ?? c.notes ?? ''}</textarea>
</div>
</div>
{#if !c.currentDeviceId}
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Storage Location</h2>
<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 ?? c.locationId)}>{loc.name}</option>
{/each}
</select>
</div>
{/if}
<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="/components/{c.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 { components } 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 [component] = await db
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
brand: components.brand,
partNumber: components.partNumber,
serialNumber: components.serialNumber
})
.from(components)
.where(eq(components.id, params.id));
if (!component) error(404, 'Component not found');
const url = `${env.BASE_URL ?? 'http://localhost:5173'}/components/${params.id}`;
const qrSvg = await generateQrSvg(url);
return { component, qrSvg };
};
@@ -0,0 +1,67 @@
<script lang="ts">
let { data } = $props();
</script>
<svelte:head>
<title>Label - {data.component.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="/components/{data.component.id}" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Back to component</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>
<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.component.title}</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{data.component.componentType}</p>
{#if data.component.brand}
<p class="text-sm text-gray-500 dark:text-gray-400">{data.component.brand}</p>
{/if}
{#if data.component.partNumber}
<p class="mt-1 font-mono text-xs text-gray-500 dark:text-gray-400">P/N: {data.component.partNumber}</p>
{/if}
{#if data.component.serialNumber}
<p class="font-mono text-xs text-gray-500 dark:text-gray-400">S/N: {data.component.serialNumber}</p>
{/if}
<p class="mt-2 font-mono text-xs text-gray-400 dark:text-gray-500">{data.component.id.slice(0, 8)}</p>
</div>
</div>
</div>
<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.component.title}</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">{data.component.componentType}</p>
{#if data.component.partNumber}
<p class="truncate font-mono text-xs text-gray-400">{data.component.partNumber}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,15 @@
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'}/components/${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,65 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, locations } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
const componentSchema = z.object({
title: z.string().min(1, 'Title is required'),
componentType: z.string().min(1, 'Component type is required'),
brand: z.string().optional(),
partNumber: z.string().optional(),
serialNumber: z.string().optional(),
condition: z.enum(['Working', 'Faulty', 'Unknown', 'Refurbished']),
firmwareVersion: z.string().optional(),
specs: z.string().optional(),
notes: z.string().optional(),
currentDeviceId: z.string().uuid().optional().or(z.literal('')),
locationId: z.string().uuid().optional().or(z.literal(''))
});
export const load: PageServerLoad = async () => {
const deviceList = await db
.select({ id: devices.id, title: devices.title })
.from(devices)
.where(eq(devices.disabled, false))
.orderBy(devices.title);
const locationList = await db
.select({ id: locations.id, name: locations.name })
.from(locations);
return { devices: deviceList, locations: locationList };
};
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
const result = componentSchema.safeParse(raw);
if (!result.success) {
return fail(400, { error: result.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const data = result.data;
const [component] = await db
.insert(components)
.values({
title: data.title,
componentType: data.componentType,
brand: data.brand || null,
partNumber: data.partNumber || null,
serialNumber: data.serialNumber || null,
condition: data.condition,
firmwareVersion: data.firmwareVersion || null,
specs: data.specs || null,
notes: data.notes || null,
currentDeviceId: data.currentDeviceId || null,
locationId: data.currentDeviceId ? null : data.locationId || null
})
.returning({ id: components.id });
redirect(303, `/components/${component!.id}`);
}
};
@@ -0,0 +1,118 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { COMPONENT_TYPES, COMPONENT_CONDITIONS } from '$lib/constants.js';
let { data, form } = $props();
</script>
<svelte:head>
<title>Add Component - 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 Component</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">
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Component Info</h2>
<div class="mb-4">
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
<input type="text" id="title" name="title" required value={form?.values?.title ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
placeholder="e.g. BlueSCSI v2 #1" />
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="componentType" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Type *</label>
<select id="componentType" name="componentType" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_TYPES as t}
<option value={t} selected={form?.values?.componentType === t}>{t}</option>
{/each}
</select>
</div>
<div>
<label for="condition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition *</label>
<select id="condition" name="condition"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{#each COMPONENT_CONDITIONS as c}
<option value={c} selected={c === (form?.values?.condition ?? 'Working')}>{c}</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>
<input type="text" id="brand" name="brand" value={form?.values?.brand ?? ''}
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="partNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Part Number</label>
<input type="text" id="partNumber" name="partNumber" value={form?.values?.partNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<label for="serialNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Serial Number</label>
<input type="text" id="serialNumber" name="serialNumber" value={form?.values?.serialNumber ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
<div class="mb-4">
<label for="firmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware Version</label>
<input type="text" id="firmwareVersion" name="firmwareVersion" value={form?.values?.firmwareVersion ?? ''}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div class="mb-4">
<label for="specs" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Specs</label>
<textarea id="specs" name="specs" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g. 32MB 72-pin SIMM">{form?.values?.specs ?? ''}</textarea>
</div>
<div>
<label for="notes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<textarea id="notes" name="notes" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{form?.values?.notes ?? ''}</textarea>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Location</h2>
<div class="mb-4">
<label for="currentDeviceId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Currently Installed In</label>
<select id="currentDeviceId" name="currentDeviceId"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Not installed (in storage)</option>
{#each data.devices as d}
<option value={d.id}>{d.title}</option>
{/each}
</select>
</div>
<div>
<label for="locationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage 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}>{loc.name}</option>
{/each}
</select>
</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">Create Component</button>
<a href="/components" 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>
+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>
+29
View File
@@ -0,0 +1,29 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { deviceImages, devices } from '$lib/server/db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url }) => {
const category = url.searchParams.get('category');
const conditions = [eq(devices.disabled, false)];
if (category) conditions.push(eq(devices.category, category));
let query = db
.select({
id: deviceImages.id,
filePath: deviceImages.filePath,
thumbnailPath: deviceImages.thumbnailPath,
caption: deviceImages.caption,
deviceId: deviceImages.deviceId,
deviceTitle: devices.title,
deviceCategory: devices.category
})
.from(deviceImages)
.innerJoin(devices, eq(deviceImages.deviceId, devices.id))
.where(and(...conditions));
const images = await query.orderBy(desc(deviceImages.uploadedAt)).limit(100);
return { images, category };
};
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { DEVICE_CATEGORIES } from '$lib/constants.js';
let { data } = $props();
function filterCategory(cat: string | null) {
const url = new URL($page.url);
if (cat) url.searchParams.set('category', cat);
else url.searchParams.delete('category');
goto(url.toString());
}
</script>
<svelte:head>
<title>Gallery - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Gallery</h1>
<div class="mb-6 flex gap-2">
<button onclick={() => filterCategory(null)}
class="rounded-md px-3 py-1.5 text-sm {!data.category ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'}">
All
</button>
{#each DEVICE_CATEGORIES as cat}
<button onclick={() => filterCategory(cat)}
class="rounded-md px-3 py-1.5 text-sm {data.category === cat ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'}">
{cat}
</button>
{/each}
</div>
{#if data.images.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 images yet. Upload images on device detail pages.</p>
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each data.images as img}
<a href="/devices/{img.deviceId}" class="group relative overflow-hidden rounded-lg">
<img src={img.thumbnailPath ?? img.filePath} alt={img.caption ?? img.deviceTitle}
class="h-48 w-full object-cover transition-transform group-hover:scale-105" />
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-3">
<p class="text-sm font-medium text-white">{img.deviceTitle}</p>
{#if img.caption}
<p class="text-xs text-white/80">{img.caption}</p>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,37 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { installationLog, components, devices } from '$lib/server/db/schema.js';
import { eq, desc, count } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url }) => {
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1));
const pageSize = 50;
const [totalResult] = await db.select({ value: count() }).from(installationLog);
const entries = await db
.select({
id: installationLog.id,
action: installationLog.action,
performedAt: installationLog.performedAt,
performedBy: installationLog.performedBy,
notes: installationLog.notes,
componentId: installationLog.componentId,
componentTitle: components.title,
deviceId: installationLog.deviceId,
deviceTitle: devices.title
})
.from(installationLog)
.innerJoin(components, eq(installationLog.componentId, components.id))
.innerJoin(devices, eq(installationLog.deviceId, devices.id))
.orderBy(desc(installationLog.performedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return {
entries,
total: totalResult?.value ?? 0,
page,
pageSize
};
};
@@ -0,0 +1,65 @@
<script lang="ts">
import { timeAgo, formatDateTime } from '$lib/utils/date.js';
let { data } = $props();
const totalPages = $derived(Math.ceil(data.total / data.pageSize));
</script>
<svelte:head>
<title>Installation Log - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Installation Log</h1>
<a href="/installations/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Log Installation
</a>
</div>
{#if data.entries.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 installation activity yet.</p>
</div>
{:else}
<div class="space-y-3">
{#each data.entries as entry}
<div class="flex items-start gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<span class="mt-0.5 rounded-full px-2.5 py-0.5 text-xs font-medium
{entry.action === 'installed' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
{entry.action === 'removed' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
{entry.action === 'swapped' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
">
{entry.action}
</span>
<div class="flex-1">
<p class="text-sm text-gray-700 dark:text-gray-300">
<a href="/components/{entry.componentId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{entry.componentTitle}</a>
{entry.action === 'installed' ? 'into' : entry.action === 'removed' ? 'from' : 'in'}
<a href="/devices/{entry.deviceId}" class="font-medium hover:text-blue-600 dark:hover:text-blue-400">{entry.deviceTitle}</a>
</p>
{#if entry.notes}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{entry.notes}</p>
{/if}
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{formatDateTime(entry.performedAt)} ({timeAgo(entry.performedAt)})
{#if entry.performedBy}&middot; {entry.performedBy}{/if}
</p>
</div>
</div>
{/each}
</div>
{/if}
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
{#if data.page > 1}
<a href="?page={data.page - 1}" 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="?page={data.page + 1}" 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,141 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { components, devices, installationLog, locations } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import pg from 'pg';
import { env } from '$env/dynamic/private';
const installSchema = z.object({
componentId: z.string().uuid('Select a component'),
deviceId: z.string().uuid('Select a device'),
action: z.enum(['installed', 'removed', 'swapped']),
performedBy: z.string().optional(),
notes: z.string().optional(),
storageLocationId: z.string().uuid().optional().or(z.literal(''))
});
export const load: PageServerLoad = async ({ url }) => {
const deviceList = await db
.select({ id: devices.id, title: devices.title })
.from(devices)
.where(eq(devices.disabled, false))
.orderBy(devices.title);
const componentList = await db
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
currentDeviceId: components.currentDeviceId
})
.from(components)
.orderBy(components.title);
const locationList = await db
.select({ id: locations.id, name: locations.name })
.from(locations);
return {
devices: deviceList,
components: componentList,
locations: locationList,
prefill: {
componentId: url.searchParams.get('componentId') ?? '',
deviceId: url.searchParams.get('deviceId') ?? '',
action: url.searchParams.get('action') ?? 'installed'
}
};
};
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
const result = installSchema.safeParse(raw);
if (!result.success) {
return fail(400, { error: result.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const data = result.data;
// Validate component exists
const [component] = await db
.select({ id: components.id, currentDeviceId: components.currentDeviceId, title: components.title })
.from(components)
.where(eq(components.id, data.componentId));
if (!component) {
return fail(400, { error: 'Component not found', values: raw });
}
// Validate device exists
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(eq(devices.id, data.deviceId));
if (!device) {
return fail(400, { error: 'Device not found', values: raw });
}
// Business logic validation
if (data.action === 'installed' && component.currentDeviceId) {
return fail(400, {
error: `${component.title} is already installed in a device. Remove it first.`,
values: raw
});
}
if (data.action === 'removed' && component.currentDeviceId !== data.deviceId) {
return fail(400, {
error: `${component.title} is not installed in this device.`,
values: raw
});
}
// Execute in a transaction using raw pg client
const pool = new pg.Pool({ connectionString: env.DATABASE_URL });
const client = await pool.connect();
try {
await client.query('BEGIN');
// Insert log entry
await client.query(
`INSERT INTO installation_log (id, component_id, device_id, action, performed_by, notes, performed_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, NOW())`,
[data.componentId, data.deviceId, data.action, data.performedBy || null, data.notes || null]
);
// Update component's current location
if (data.action === 'installed') {
await client.query(
`UPDATE components SET current_device_id = $1, location_id = NULL, updated_at = NOW() WHERE id = $2`,
[data.deviceId, data.componentId]
);
} else if (data.action === 'removed') {
await client.query(
`UPDATE components SET current_device_id = NULL, location_id = $1, updated_at = NOW() WHERE id = $2`,
[data.storageLocationId || null, data.componentId]
);
} else if (data.action === 'swapped') {
// For swap: mark as installed in the target device
await client.query(
`UPDATE components SET current_device_id = $1, location_id = NULL, updated_at = NOW() WHERE id = $2`,
[data.deviceId, data.componentId]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
await pool.end();
}
redirect(303, `/devices/${data.deviceId}`);
}
};
@@ -0,0 +1,92 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { INSTALLATION_ACTIONS } from '$lib/constants.js';
let { data, form } = $props();
let action = $state(form?.values?.action ?? data.prefill.action ?? 'installed');
</script>
<svelte:head>
<title>Log Installation - 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">Log Installation</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">
<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">Installation Details</h2>
<div class="mb-4">
<label for="action" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Action *</label>
<select id="action" name="action" bind:value={action}
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 INSTALLATION_ACTIONS as a}
<option value={a} class="capitalize">{a}</option>
{/each}
</select>
</div>
<div class="mb-4">
<label for="componentId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Component *</label>
<select id="componentId" name="componentId" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select component...</option>
{#each data.components as comp}
<option value={comp.id} selected={comp.id === (form?.values?.componentId ?? data.prefill.componentId)}>
{comp.title} ({comp.componentType})
{#if comp.currentDeviceId}[installed]{/if}
</option>
{/each}
</select>
</div>
<div class="mb-4">
<label for="deviceId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Device *</label>
<select id="deviceId" name="deviceId" required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select device...</option>
{#each data.devices as d}
<option value={d.id} selected={d.id === (form?.values?.deviceId ?? data.prefill.deviceId)}>{d.title}</option>
{/each}
</select>
</div>
{#if action === 'removed'}
<div class="mb-4">
<label for="storageLocationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage Location</label>
<select id="storageLocationId" name="storageLocationId"
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}>{loc.name}</option>
{/each}
</select>
</div>
{/if}
<div class="mb-4">
<label for="performedBy" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Performed By</label>
<input type="text" id="performedBy" name="performedBy" value={form?.values?.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>
<div>
<label for="notes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<textarea id="notes" name="notes" 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"
placeholder="Reason for installation, observations...">{form?.values?.notes ?? ''}</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">Log Installation</button>
<a href="/installations" 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,80 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { locations, devices, components } from '$lib/server/db/schema.js';
import { eq, count, isNull } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
export const load: PageServerLoad = async () => {
const locationList = await db
.select({
id: locations.id,
name: locations.name,
description: locations.description,
parentId: locations.parentId
})
.from(locations)
.orderBy(locations.name);
// Count devices and components per location
const deviceCounts = await db
.select({ locationId: devices.locationId, count: count() })
.from(devices)
.groupBy(devices.locationId);
const componentCounts = await db
.select({ locationId: components.locationId, count: count() })
.from(components)
.where(isNull(components.currentDeviceId))
.groupBy(components.locationId);
const dcMap: Record<string, number> = {};
for (const r of deviceCounts) {
if (r.locationId) dcMap[r.locationId] = r.count;
}
const ccMap: Record<string, number> = {};
for (const r of componentCounts) {
if (r.locationId) ccMap[r.locationId] = r.count;
}
return {
locations: locationList.map((l) => ({
...l,
deviceCount: dcMap[l.id] ?? 0,
componentCount: ccMap[l.id] ?? 0
}))
};
};
const locationSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
parentId: z.string().uuid().optional().or(z.literal(''))
});
export const actions: Actions = {
create: async ({ request }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
const result = locationSchema.safeParse(raw);
if (!result.success) {
return fail(400, { error: result.error.errors[0]?.message });
}
await db.insert(locations).values({
name: result.data.name,
description: result.data.description || null,
parentId: result.data.parentId || null
});
return { created: true };
},
delete: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
// Clear references first
await db.update(devices).set({ locationId: null }).where(eq(devices.locationId, id));
await db.update(components).set({ locationId: null }).where(eq(components.locationId, id));
await db.delete(locations).where(eq(locations.id, id));
return { deleted: true };
}
};
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let showForm = $state(false);
type Loc = (typeof data.locations)[number] & { depth: number };
const sortedLocations = $derived.by(() => {
const result: Loc[] = [];
const childrenMap = new Map<string | null, typeof data.locations>();
for (const loc of data.locations) {
const key = loc.parentId ?? null;
if (!childrenMap.has(key)) childrenMap.set(key, []);
childrenMap.get(key)!.push(loc);
}
function walk(parentId: string | null, depth: number) {
const children = childrenMap.get(parentId) ?? [];
for (const child of children) {
result.push({ ...child, depth });
walk(child.id, depth + 1);
}
}
walk(null, 0);
return result;
});
</script>
<svelte:head>
<title>Locations - B4L Repair</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Locations</h1>
<button onclick={() => (showForm = !showForm)}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
{showForm ? 'Cancel' : 'Add Location'}
</button>
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
{#if showForm}
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<form method="POST" action="?/create" use:enhance class="flex flex-wrap items-end gap-3">
<div class="flex-1">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name *</label>
<input type="text" id="name" name="name" 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="e.g. Workshop Shelf A" />
</div>
<div class="flex-1">
<label for="description" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<input type="text" id="description" name="description"
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="parentId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Parent</label>
<select id="parentId" name="parentId"
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="">None (top level)</option>
{#each sortedLocations as loc}
<option value={loc.id}>{'—'.repeat(loc.depth)} {loc.name}</option>
{/each}
</select>
</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</button>
</form>
</div>
{/if}
{#if data.locations.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 locations yet. Add your first location to start organizing.</p>
</div>
{:else}
<div class="space-y-2">
{#each sortedLocations as loc}
<div class="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
style="margin-left: {loc.depth * 1.5}rem">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
{#if loc.depth > 0}
<span class="text-gray-400 dark:text-gray-500">&nbsp;</span>
{/if}
{loc.name}
</h3>
{#if loc.description}
<p class="text-sm text-gray-500 dark:text-gray-400">{loc.description}</p>
{/if}
</div>
<div class="flex items-center gap-4">
<div class="text-right text-xs text-gray-400 dark:text-gray-500">
{#if loc.deviceCount > 0}
<span>{loc.deviceCount} device{loc.deviceCount !== 1 ? 's' : ''}</span>
{/if}
{#if loc.componentCount > 0}
<span class="ml-2">{loc.componentCount} component{loc.componentCount !== 1 ? 's' : ''}</span>
{/if}
</div>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={loc.id} />
<button type="submit" class="text-sm text-red-600 hover:text-red-700 dark:text-red-400" title="Delete">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</form>
</div>
</div>
{/each}
</div>
{/if}
</div>
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+28
View File
@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { components } from '$lib/server/db/schema.js';
import { ilike, or } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const results = await db
.select({
id: components.id,
title: components.title,
componentType: components.componentType,
currentDeviceId: components.currentDeviceId
})
.from(components)
.where(
or(
ilike(components.title, `%${q}%`),
ilike(components.brand, `%${q}%`),
ilike(components.partNumber, `%${q}%`)
)
)
.limit(20);
return json(results);
};
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices } from '$lib/server/db/schema.js';
import { ilike, or, and, eq } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const results = await db
.select({ id: devices.id, title: devices.title, brand: devices.brand, model: devices.model })
.from(devices)
.where(
and(
eq(devices.disabled, false),
or(
ilike(devices.title, `%${q}%`),
ilike(devices.brand, `%${q}%`),
ilike(devices.model, `%${q}%`)
)
)
)
.limit(20);
return json(results);
};
+18
View File
@@ -0,0 +1,18 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices } from '$lib/server/db/schema.js';
import { ilike, and, eq } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const results = await db
.selectDistinct({ brand: devices.brand })
.from(devices)
.where(and(eq(devices.disabled, false), ilike(devices.brand, `%${q}%`)))
.orderBy(devices.brand)
.limit(20);
return json(results.filter((r) => r.brand).map((r) => r.brand));
};
+22
View File
@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices } from '$lib/server/db/schema.js';
import { ilike, eq, and } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const brand = url.searchParams.get('brand');
const conditions = [eq(devices.disabled, false), ilike(devices.model, `%${q}%`)];
if (brand) conditions.push(eq(devices.brand, brand));
const results = await db
.selectDistinct({ model: devices.model })
.from(devices)
.where(and(...conditions))
.orderBy(devices.model)
.limit(20);
return json(results.filter((r) => r.model).map((r) => r.model));
};
+58
View File
@@ -0,0 +1,58 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices, components } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { redirect, error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q')?.trim() ?? '';
if (!q) redirect(303, '/');
// Check if it's a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Extract UUID from a full URL if pasted
let id = q;
const urlMatch = q.match(/\/(devices|components)\/([0-9a-f-]{36})/i);
if (urlMatch) {
const type = urlMatch[1];
id = urlMatch[2];
redirect(303, `/${type}/${id}`);
}
if (uuidRegex.test(id)) {
// Try device first
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(eq(devices.id, id));
if (device) redirect(303, `/devices/${device.id}`);
// Try component
const [component] = await db
.select({ id: components.id })
.from(components)
.where(eq(components.id, id));
if (component) redirect(303, `/components/${component.id}`);
}
// Try partial ID match (first 8 chars)
if (id.length >= 6) {
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(eq(devices.disabled, false))
.then(rows => rows.filter(r => r.id.startsWith(id)));
if (device) redirect(303, `/devices/${device.id}`);
const [component] = await db
.select({ id: components.id })
.from(components)
.then(rows => rows.filter(r => r.id.startsWith(id)));
if (component) redirect(303, `/components/${component.id}`);
}
// Nothing found — redirect to devices with search
redirect(303, `/devices?q=${encodeURIComponent(q)}`);
};