Initial commit: buildfor_life_repair inventory system
SvelteKit + PostgreSQL app for tracking vintage computers, audio equipment, components, and installation history. Features device/component CRUD, operation logs, QR code labels, global search, image uploads, and dark mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
@@ -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')`)
|
||||
]
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user