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
+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 });
}