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,3 @@
|
||||
DATABASE_URL=postgresql://bflr:bflr_dev@localhost:5432/buildfor_life_repair
|
||||
UPLOAD_DIR=static/uploads
|
||||
BASE_URL=http://localhost:5173
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
static/uploads/**
|
||||
!static/uploads/.gitkeep
|
||||
*.db
|
||||
@@ -0,0 +1,106 @@
|
||||
# buildfor_life_repair
|
||||
|
||||
Inventory and repair tracking system for vintage computers and audio equipment. Built with SvelteKit, PostgreSQL, and Tailwind CSS.
|
||||
|
||||
## Features
|
||||
|
||||
- **Device tracking** — computers, audio equipment, peripherals with full specs, images, documents
|
||||
- **Component tracking** — individual parts (BlueSCSI, RAM, logic boards, etc.) linked to devices
|
||||
- **Installation log** — full history of component installs/removals with timestamps
|
||||
- **Operation/repair log** — per-device repair, inspection, cleaning, modification history
|
||||
- **QR code labels** — printable labels for devices and components, scannable for quick lookup
|
||||
- **Global search** — find devices/components by ID, name, or scanned QR code
|
||||
- **Dark mode** — follows system preference or manual toggle
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) 18+
|
||||
- [PostgreSQL](https://www.postgresql.org/) 14+ (or Docker)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Set up the database
|
||||
|
||||
**Option A: Use your own PostgreSQL instance**
|
||||
|
||||
```sql
|
||||
CREATE USER bflr WITH PASSWORD 'bflr_dev';
|
||||
CREATE DATABASE buildfor_life_repair OWNER bflr;
|
||||
\c buildfor_life_repair
|
||||
GRANT ALL PRIVILEGES ON DATABASE buildfor_life_repair TO bflr;
|
||||
GRANT ALL ON SCHEMA public TO bflr;
|
||||
```
|
||||
|
||||
**Option B: Use Docker**
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
Copy the example env file and edit as needed:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your database connection string:
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://bflr:bflr_dev@localhost:5432/buildfor_life_repair
|
||||
UPLOAD_DIR=static/uploads
|
||||
BASE_URL=http://localhost:5173
|
||||
```
|
||||
|
||||
### 4. Run database migrations
|
||||
|
||||
Push the schema to your database:
|
||||
|
||||
```bash
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
Or generate and run migrations:
|
||||
|
||||
```bash
|
||||
npm run db:generate
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
### 5. Start the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at [http://localhost:5173](http://localhost:5173).
|
||||
|
||||
## Database commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run db:push` | Push schema changes directly to the database |
|
||||
| `npm run db:generate` | Generate SQL migration files from schema changes |
|
||||
| `npm run db:migrate` | Run pending migrations |
|
||||
| `npm run db:studio` | Open Drizzle Studio (visual database browser) |
|
||||
|
||||
## Build for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Frontend:** SvelteKit 5, Svelte 5 (runes), Tailwind CSS v4
|
||||
- **Backend:** SvelteKit server, Drizzle ORM, PostgreSQL
|
||||
- **Validation:** Zod
|
||||
- **Other:** sharp (image thumbnails), qrcode (QR generation), date-fns
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: buildfor_life_repair
|
||||
POSTGRES_USER: bflr
|
||||
POSTGRES_PASSWORD: bflr_dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
out: './drizzle/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!
|
||||
}
|
||||
});
|
||||
Generated
+4604
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "buildfor-life-repair",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"pg": "^8.13.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.33.5",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.15.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"svelte": "^5.19.0",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {}
|
||||
interface PageData {}
|
||||
interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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 }
|
||||
};
|
||||
};
|
||||
@@ -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}· {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}· {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">← 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>
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { devices, deviceImages, locations } from '$lib/server/db/schema.js';
|
||||
import { eq, ilike, or, count, desc, and, sql } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const category = url.searchParams.get('category');
|
||||
const condition = url.searchParams.get('condition');
|
||||
const search = url.searchParams.get('q');
|
||||
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1));
|
||||
const pageSize = 24;
|
||||
|
||||
const conditions = [eq(devices.disabled, false)];
|
||||
|
||||
if (category) {
|
||||
conditions.push(eq(devices.category, category));
|
||||
}
|
||||
if (condition === 'needs-repair') {
|
||||
conditions.push(
|
||||
or(eq(devices.condition, 'In Repair'), eq(devices.condition, 'Waiting for Repair'))!
|
||||
);
|
||||
} else if (condition) {
|
||||
conditions.push(eq(devices.condition, condition));
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(devices.title, `%${search}%`),
|
||||
ilike(devices.brand, `%${search}%`),
|
||||
ilike(devices.model, `%${search}%`),
|
||||
ilike(devices.serialNumber, `%${search}%`)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db.select({ value: count() }).from(devices).where(where);
|
||||
const total = totalResult?.value ?? 0;
|
||||
|
||||
const deviceList = await db
|
||||
.select({
|
||||
id: devices.id,
|
||||
title: devices.title,
|
||||
category: devices.category,
|
||||
brand: devices.brand,
|
||||
model: devices.model,
|
||||
condition: devices.condition,
|
||||
year: devices.year,
|
||||
locationName: locations.name
|
||||
})
|
||||
.from(devices)
|
||||
.leftJoin(locations, eq(devices.locationId, locations.id))
|
||||
.where(where)
|
||||
.orderBy(desc(devices.updatedAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
|
||||
// Fetch first image for each device
|
||||
const deviceIds = deviceList.map((d) => d.id);
|
||||
let imageMap: Record<string, string> = {};
|
||||
if (deviceIds.length > 0) {
|
||||
const images = await db
|
||||
.select({
|
||||
deviceId: deviceImages.deviceId,
|
||||
thumbnailPath: deviceImages.thumbnailPath,
|
||||
filePath: deviceImages.filePath
|
||||
})
|
||||
.from(deviceImages)
|
||||
.where(sql`${deviceImages.deviceId} IN ${deviceIds}`)
|
||||
.orderBy(deviceImages.sortOrder);
|
||||
|
||||
for (const img of images) {
|
||||
if (!imageMap[img.deviceId]) {
|
||||
imageMap[img.deviceId] = img.thumbnailPath ?? img.filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
devices: deviceList.map((d) => ({
|
||||
...d,
|
||||
thumbnail: imageMap[d.id] ?? null
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
filters: { category, condition, search }
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { DEVICE_CATEGORIES, DEVICE_CONDITIONS } from '$lib/constants.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let search = $state(data.filters.search ?? '');
|
||||
|
||||
function applyFilter(key: string, value: string | null) {
|
||||
const url = new URL($page.url);
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
url.searchParams.delete('page');
|
||||
goto(url.toString());
|
||||
}
|
||||
|
||||
function handleSearch(e: Event) {
|
||||
e.preventDefault();
|
||||
applyFilter('q', search || null);
|
||||
}
|
||||
|
||||
const totalPages = $derived(Math.ceil(data.total / data.pageSize));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Devices - B4L Repair</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Devices</h1>
|
||||
<a
|
||||
href="/devices/new"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add Device
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<form onsubmit={handleSearch} class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Search devices..."
|
||||
class="w-full max-w-sm rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
/>
|
||||
</form>
|
||||
<select
|
||||
onchange={(e) => applyFilter('category', (e.target as HTMLSelectElement).value || null)}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{#each DEVICE_CATEGORIES as cat}
|
||||
<option value={cat} selected={data.filters.category === cat}>{cat}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
onchange={(e) => applyFilter('condition', (e.target as HTMLSelectElement).value || null)}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Conditions</option>
|
||||
<option value="needs-repair" selected={data.filters.condition === 'needs-repair'}>Needs Repair</option>
|
||||
{#each DEVICE_CONDITIONS as cond}
|
||||
<option value={cond} selected={data.filters.condition === cond}>{cond}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Results count -->
|
||||
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">{data.total} device{data.total !== 1 ? 's' : ''}</p>
|
||||
|
||||
<!-- Device Grid -->
|
||||
{#if data.devices.length === 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-gray-500 dark:text-gray-400">No devices found.</p>
|
||||
<a href="/devices/new" class="mt-2 inline-block text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">Add your first device</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.devices as device}
|
||||
<a
|
||||
href="/devices/{device.id}"
|
||||
class="rounded-lg border border-gray-200 bg-white transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="flex h-40 items-center justify-center overflow-hidden rounded-t-lg bg-gray-100 dark:bg-gray-700">
|
||||
{#if device.thumbnail}
|
||||
<img src={device.thumbnail} alt={device.title} class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<svg class="h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{device.title}</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{[device.brand, device.model].filter(Boolean).join(' ') || device.category}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{device.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
|
||||
{device.condition === 'In Repair' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : ''}
|
||||
{device.condition === 'Waiting for Repair' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' : ''}
|
||||
{device.condition === 'Waiting to be Tested' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
|
||||
{device.condition === 'Unrepairable' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
|
||||
">
|
||||
{device.condition}
|
||||
</span>
|
||||
{#if device.year}
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{device.year}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if device.locationName}
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{device.locationName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-center gap-2">
|
||||
{#if data.page > 1}
|
||||
<a
|
||||
href="?{new URLSearchParams({ ...(data.filters.category ? { category: data.filters.category } : {}), ...(data.filters.condition ? { condition: data.filters.condition } : {}), ...(data.filters.search ? { q: data.filters.search } : {}), page: String(data.page - 1) }).toString()}"
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
{/if}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Page {data.page} of {totalPages}</span>
|
||||
{#if data.page < totalPages}
|
||||
<a
|
||||
href="?{new URLSearchParams({ ...(data.filters.category ? { category: data.filters.category } : {}), ...(data.filters.condition ? { condition: data.filters.condition } : {}), ...(data.filters.search ? { q: data.filters.search } : {}), page: String(data.page + 1) }).toString()}"
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
devices,
|
||||
computerDetails,
|
||||
deviceImages,
|
||||
deviceDocuments,
|
||||
components,
|
||||
installationLog,
|
||||
deviceLog,
|
||||
locations
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { saveImage, saveDocument, deleteFile } from '$lib/server/uploads.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const [device] = await db
|
||||
.select({
|
||||
id: devices.id,
|
||||
title: devices.title,
|
||||
category: devices.category,
|
||||
brand: devices.brand,
|
||||
model: devices.model,
|
||||
serialNumber: devices.serialNumber,
|
||||
year: devices.year,
|
||||
condition: devices.condition,
|
||||
voltage: devices.voltage,
|
||||
frequency: devices.frequency,
|
||||
origin: devices.origin,
|
||||
faultDescription: devices.faultDescription,
|
||||
repairNotes: devices.repairNotes,
|
||||
initialCondition: devices.initialCondition,
|
||||
generalNotes: devices.generalNotes,
|
||||
locationId: devices.locationId,
|
||||
locationName: locations.name,
|
||||
disabled: devices.disabled,
|
||||
createdAt: devices.createdAt,
|
||||
updatedAt: devices.updatedAt
|
||||
})
|
||||
.from(devices)
|
||||
.leftJoin(locations, eq(devices.locationId, locations.id))
|
||||
.where(eq(devices.id, params.id));
|
||||
|
||||
if (!device) error(404, 'Device not found');
|
||||
if (device.disabled) error(404, 'Device not found');
|
||||
|
||||
// Computer details
|
||||
let compDetails = null;
|
||||
if (device.category === 'Computer') {
|
||||
const [cd] = await db
|
||||
.select()
|
||||
.from(computerDetails)
|
||||
.where(eq(computerDetails.deviceId, params.id));
|
||||
compDetails = cd ?? null;
|
||||
}
|
||||
|
||||
// Images
|
||||
const images = await db
|
||||
.select()
|
||||
.from(deviceImages)
|
||||
.where(eq(deviceImages.deviceId, params.id))
|
||||
.orderBy(deviceImages.sortOrder);
|
||||
|
||||
// Documents
|
||||
const documents = await db
|
||||
.select()
|
||||
.from(deviceDocuments)
|
||||
.where(eq(deviceDocuments.deviceId, params.id));
|
||||
|
||||
// Installed components
|
||||
const installedComponents = await db
|
||||
.select({
|
||||
id: components.id,
|
||||
title: components.title,
|
||||
componentType: components.componentType,
|
||||
condition: components.condition
|
||||
})
|
||||
.from(components)
|
||||
.where(eq(components.currentDeviceId, params.id));
|
||||
|
||||
// Installation history
|
||||
const history = await db
|
||||
.select({
|
||||
id: installationLog.id,
|
||||
action: installationLog.action,
|
||||
performedAt: installationLog.performedAt,
|
||||
performedBy: installationLog.performedBy,
|
||||
notes: installationLog.notes,
|
||||
componentId: installationLog.componentId,
|
||||
componentTitle: components.title
|
||||
})
|
||||
.from(installationLog)
|
||||
.innerJoin(components, eq(installationLog.componentId, components.id))
|
||||
.where(eq(installationLog.deviceId, params.id))
|
||||
.orderBy(desc(installationLog.performedAt));
|
||||
|
||||
// Device operation/repair log
|
||||
const operationLog = await db
|
||||
.select()
|
||||
.from(deviceLog)
|
||||
.where(eq(deviceLog.deviceId, params.id))
|
||||
.orderBy(desc(deviceLog.performedAt));
|
||||
|
||||
return {
|
||||
device,
|
||||
computerDetails: compDetails,
|
||||
images,
|
||||
documents,
|
||||
installedComponents,
|
||||
history,
|
||||
operationLog
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateStatus: async ({ request, params }) => {
|
||||
const formData = await request.formData();
|
||||
const condition = formData.get('condition') as string;
|
||||
const repairNotes = formData.get('repairNotes') as string;
|
||||
|
||||
await db
|
||||
.update(devices)
|
||||
.set({
|
||||
condition,
|
||||
repairNotes: repairNotes || null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(devices.id, params.id));
|
||||
|
||||
return { statusUpdated: true };
|
||||
},
|
||||
|
||||
addLogEntry: async ({ request, params }) => {
|
||||
const formData = await request.formData();
|
||||
const type = formData.get('type') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const conditionAfter = formData.get('conditionAfter') as string;
|
||||
const performedBy = formData.get('performedBy') as string;
|
||||
|
||||
if (!description) {
|
||||
return fail(400, { error: 'Description is required' });
|
||||
}
|
||||
|
||||
await db.insert(deviceLog).values({
|
||||
deviceId: params.id,
|
||||
type: type || 'other',
|
||||
description,
|
||||
conditionAfter: conditionAfter || null,
|
||||
performedBy: performedBy || null
|
||||
});
|
||||
|
||||
// Update device condition if provided
|
||||
if (conditionAfter) {
|
||||
await db
|
||||
.update(devices)
|
||||
.set({ condition: conditionAfter, updatedAt: new Date() })
|
||||
.where(eq(devices.id, params.id));
|
||||
}
|
||||
|
||||
return { logAdded: true };
|
||||
},
|
||||
|
||||
uploadImage: async ({ request, params }) => {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image') as File;
|
||||
if (!file || file.size === 0) return fail(400, { error: 'No file selected' });
|
||||
|
||||
const { filePath, thumbnailPath } = await saveImage(file, 'devices');
|
||||
const caption = formData.get('caption') as string;
|
||||
|
||||
await db.insert(deviceImages).values({
|
||||
deviceId: params.id,
|
||||
filePath,
|
||||
thumbnailPath,
|
||||
caption: caption || null
|
||||
});
|
||||
|
||||
return { imageUploaded: true };
|
||||
},
|
||||
|
||||
deleteImage: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const imageId = formData.get('imageId') as string;
|
||||
|
||||
const [img] = await db.select().from(deviceImages).where(eq(deviceImages.id, imageId));
|
||||
if (img) {
|
||||
await deleteFile(img.filePath);
|
||||
if (img.thumbnailPath) await deleteFile(img.thumbnailPath);
|
||||
await db.delete(deviceImages).where(eq(deviceImages.id, imageId));
|
||||
}
|
||||
|
||||
return { imageDeleted: true };
|
||||
},
|
||||
|
||||
uploadDocument: async ({ request, params }) => {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('document') as File;
|
||||
if (!file || file.size === 0) return fail(400, { error: 'No file selected' });
|
||||
|
||||
const { filePath, originalFilename } = await saveDocument(file);
|
||||
const description = formData.get('description') as string;
|
||||
|
||||
await db.insert(deviceDocuments).values({
|
||||
deviceId: params.id,
|
||||
filePath,
|
||||
originalFilename,
|
||||
fileType: file.type,
|
||||
description: description || null
|
||||
});
|
||||
|
||||
return { documentUploaded: true };
|
||||
},
|
||||
|
||||
disable: async ({ params }) => {
|
||||
await db
|
||||
.update(devices)
|
||||
.set({ disabled: true, updatedAt: new Date() })
|
||||
.where(eq(devices.id, params.id));
|
||||
|
||||
redirect(303, '/devices');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,447 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { DEVICE_CONDITIONS, DEVICE_LOG_TYPES } from '$lib/constants.js';
|
||||
import { formatDate, timeAgo } from '$lib/utils/date.js';
|
||||
|
||||
let { data } = $props();
|
||||
let showStatusForm = $state(false);
|
||||
let showUploadForm = $state(false);
|
||||
let showDocForm = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let showLogForm = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.device.title} - B4L Repair</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="mb-1 flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.device.title}</h1>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{data.device.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
|
||||
{data.device.condition === 'In Repair' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : ''}
|
||||
{data.device.condition === 'Waiting for Repair' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' : ''}
|
||||
{data.device.condition === 'Waiting to be Tested' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
|
||||
{data.device.condition === 'Unrepairable' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
|
||||
">
|
||||
{data.device.condition}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{data.device.category}
|
||||
{#if data.device.brand || data.device.model}
|
||||
· {[data.device.brand, data.device.model].filter(Boolean).join(' ')}
|
||||
{/if}
|
||||
{#if data.device.year}
|
||||
· {data.device.year}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => (showStatusForm = !showStatusForm)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
Update Status
|
||||
</button>
|
||||
<a href="/devices/{data.device.id}/edit"
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
Edit
|
||||
</a>
|
||||
<a href="/devices/{data.device.id}/label"
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
QR Label
|
||||
</a>
|
||||
<a href="/installations/new?deviceId={data.device.id}"
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Install Component
|
||||
</a>
|
||||
<button onclick={() => (showDeleteConfirm = !showDeleteConfirm)}
|
||||
class="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
{#if showDeleteConfirm}
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 p-5 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p class="mb-3 text-sm text-red-700 dark:text-red-300">Are you sure you want to delete <strong>{data.device.title}</strong>? This will hide it from all listings.</p>
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="?/disable" use:enhance>
|
||||
<button type="submit" class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">Yes, delete</button>
|
||||
</form>
|
||||
<button onclick={() => (showDeleteConfirm = false)}
|
||||
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Status Update -->
|
||||
{#if showStatusForm}
|
||||
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Quick Status Update</h2>
|
||||
<form method="POST" action="?/updateStatus" use:enhance class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label for="condition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition</label>
|
||||
<select id="condition" name="condition"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
{#each DEVICE_CONDITIONS as cond}
|
||||
<option value={cond} selected={cond === data.device.condition}>{cond}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label for="repairNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Repair Notes</label>
|
||||
<input type="text" id="repairNotes" name="repairNotes" value={data.device.repairNotes ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<!-- Main Content (2 cols) -->
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<!-- Images -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Images</h2>
|
||||
<button onclick={() => (showUploadForm = !showUploadForm)}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
{showUploadForm ? 'Cancel' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showUploadForm}
|
||||
<form method="POST" action="?/uploadImage" enctype="multipart/form-data" use:enhance class="mb-4 flex flex-wrap items-end gap-3">
|
||||
<input type="file" name="image" accept="image/*" required
|
||||
class="text-sm text-gray-600 dark:text-gray-400" />
|
||||
<input type="text" name="caption" placeholder="Caption (optional)"
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.images.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No images yet.</p>
|
||||
{:else}
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
{#each data.images as img}
|
||||
<div class="group relative overflow-hidden rounded-md">
|
||||
<img src={img.filePath} alt={img.caption ?? data.device.title} class="h-32 w-full object-cover" />
|
||||
<form method="POST" action="?/deleteImage" use:enhance
|
||||
class="absolute top-1 right-1 hidden group-hover:block">
|
||||
<input type="hidden" name="imageId" value={img.id} />
|
||||
<button type="submit" class="rounded bg-red-600 p-1 text-xs text-white hover:bg-red-700" title="Delete">
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Installed Components -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Installed Components</h2>
|
||||
{#if data.installedComponents.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No components installed.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each data.installedComponents as comp}
|
||||
<a href="/components/{comp.id}"
|
||||
class="flex items-center justify-between rounded-md border border-gray-100 p-3 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/50">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{comp.title}</span>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{comp.componentType}</span>
|
||||
</div>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{comp.condition === 'Working' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
|
||||
{comp.condition === 'Faulty' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : ''}
|
||||
{comp.condition === 'Unknown' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : ''}
|
||||
{comp.condition === 'Refurbished' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
|
||||
">
|
||||
{comp.condition}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Installation History -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Installation History</h2>
|
||||
{#if data.history.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No installation history.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each data.history as entry}
|
||||
<div class="flex items-start gap-3 border-l-2 pl-3
|
||||
{entry.action === 'installed' ? 'border-green-400' : ''}
|
||||
{entry.action === 'removed' ? 'border-red-400' : ''}
|
||||
{entry.action === 'swapped' ? 'border-blue-400' : ''}
|
||||
">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium capitalize">{entry.action}</span>
|
||||
<a href="/components/{entry.componentId}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{entry.componentTitle}</a>
|
||||
</p>
|
||||
{#if entry.notes}
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{entry.notes}</p>
|
||||
{/if}
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{timeAgo(entry.performedAt)}
|
||||
{#if entry.performedBy}· {entry.performedBy}{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Initial Condition -->
|
||||
{#if data.device.initialCondition}
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-5 dark:border-blue-800 dark:bg-blue-900/10">
|
||||
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-blue-500 dark:text-blue-400">Condition on Intake</h2>
|
||||
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.device.initialCondition}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Condition & Repair -->
|
||||
{#if data.device.faultDescription || data.device.repairNotes}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Condition & Repair</h2>
|
||||
{#if data.device.faultDescription}
|
||||
<div class="mb-3">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Fault</span>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{data.device.faultDescription}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.device.repairNotes}
|
||||
<div>
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Repair Notes</span>
|
||||
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.device.repairNotes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Operation / Repair Log -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Operation Log</h2>
|
||||
<button onclick={() => (showLogForm = !showLogForm)}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
{showLogForm ? 'Cancel' : 'Add Entry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showLogForm}
|
||||
<form method="POST" action="?/addLogEntry" use:enhance class="mb-4 space-y-3 rounded-md border border-gray-100 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="logType" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Type</label>
|
||||
<select id="logType" name="type"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
{#each DEVICE_LOG_TYPES as t}
|
||||
<option value={t} class="capitalize">{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="conditionAfter" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition After</label>
|
||||
<select id="conditionAfter" name="conditionAfter"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">No change</option>
|
||||
{#each DEVICE_CONDITIONS as cond}
|
||||
<option value={cond} selected={cond === data.device.condition}>{cond}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="logDescription" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description *</label>
|
||||
<textarea id="logDescription" name="description" rows="3" required
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="What was done, what was found, parts replaced..."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="logPerformedBy" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Performed By</label>
|
||||
<input type="text" id="logPerformedBy" name="performedBy"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Add Entry</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.operationLog.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No operations logged.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each data.operationLog as entry}
|
||||
<div class="flex items-start gap-3 border-l-2 pl-3
|
||||
{entry.type === 'repair' ? 'border-amber-400' : ''}
|
||||
{entry.type === 'inspection' ? 'border-blue-400' : ''}
|
||||
{entry.type === 'cleaning' ? 'border-green-400' : ''}
|
||||
{entry.type === 'modification' ? 'border-purple-400' : ''}
|
||||
{entry.type === 'diagnostic' ? 'border-cyan-400' : ''}
|
||||
{entry.type === 'recap' ? 'border-orange-400' : ''}
|
||||
{entry.type === 'other' ? 'border-gray-400' : ''}
|
||||
">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium capitalize
|
||||
{entry.type === 'repair' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : ''}
|
||||
{entry.type === 'inspection' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : ''}
|
||||
{entry.type === 'cleaning' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : ''}
|
||||
{entry.type === 'modification' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' : ''}
|
||||
{entry.type === 'diagnostic' ? 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300' : ''}
|
||||
{entry.type === 'recap' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300' : ''}
|
||||
{entry.type === 'other' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : ''}
|
||||
">
|
||||
{entry.type}
|
||||
</span>
|
||||
{#if entry.conditionAfter}
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">→ {entry.conditionAfter}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{entry.description}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{timeAgo(entry.performedAt)}
|
||||
{#if entry.performedBy}· {entry.performedBy}{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (1 col) -->
|
||||
<div class="space-y-6">
|
||||
<!-- Details -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Details</h2>
|
||||
<dl class="space-y-2 text-sm">
|
||||
{#if data.device.serialNumber}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Serial Number</dt>
|
||||
<dd class="font-mono text-gray-900 dark:text-white">{data.device.serialNumber}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.device.voltage}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Voltage</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{data.device.voltage}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.device.frequency}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Frequency</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{data.device.frequency}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.device.origin}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Origin</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{data.device.origin}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.device.locationName}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{data.device.locationName}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Added</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{formatDate(data.device.createdAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Computer Details -->
|
||||
{#if data.computerDetails}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Computer Config</h2>
|
||||
<dl class="space-y-2 text-sm">
|
||||
{#if data.computerDetails.osVersion}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">OS</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{data.computerDetails.osVersion}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.computerDetails.firmwareVersion}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Firmware/ROM</dt>
|
||||
<dd class="font-mono text-gray-900 dark:text-white">{data.computerDetails.firmwareVersion}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.computerDetails.installedSoftware}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Software</dt>
|
||||
<dd class="whitespace-pre-wrap text-gray-900 dark:text-white">{data.computerDetails.installedSoftware}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Documents -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Documents</h2>
|
||||
<button onclick={() => (showDocForm = !showDocForm)}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
{showDocForm ? 'Cancel' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showDocForm}
|
||||
<form method="POST" action="?/uploadDocument" enctype="multipart/form-data" use:enhance class="mb-3 space-y-2">
|
||||
<input type="file" name="document" required class="text-sm text-gray-600 dark:text-gray-400" />
|
||||
<input type="text" name="description" placeholder="Description"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.documents.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each data.documents as doc}
|
||||
<a href={doc.filePath} target="_blank"
|
||||
class="flex items-center gap-2 rounded-md p-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="text-gray-700 dark:text-gray-300">{doc.originalFilename}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- General Notes -->
|
||||
{#if data.device.generalNotes}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Notes</h2>
|
||||
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.device.generalNotes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { devices, computerDetails, locations } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
const deviceSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
category: z.enum(['Computer', 'Audio Equipment', 'Peripheral', 'Other']),
|
||||
brand: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
serialNumber: z.string().optional(),
|
||||
year: z.coerce.number().int().min(1900).max(2100).optional().or(z.literal('')),
|
||||
condition: z.enum(['Working', 'In Repair', 'Waiting for Repair', 'Waiting to be Tested', 'Unrepairable']),
|
||||
voltage: z.string().optional(),
|
||||
frequency: z.string().optional(),
|
||||
origin: z.string().optional(),
|
||||
faultDescription: z.string().optional(),
|
||||
repairNotes: z.string().optional(),
|
||||
locationId: z.string().uuid().optional().or(z.literal('')),
|
||||
generalNotes: z.string().optional(),
|
||||
osVersion: z.string().optional(),
|
||||
firmwareVersion: z.string().optional(),
|
||||
installedSoftware: z.string().optional()
|
||||
});
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const [device] = await db.select().from(devices).where(eq(devices.id, params.id));
|
||||
if (!device) error(404, 'Device not found');
|
||||
|
||||
let compDetails = null;
|
||||
if (device.category === 'Computer') {
|
||||
const [cd] = await db.select().from(computerDetails).where(eq(computerDetails.deviceId, params.id));
|
||||
compDetails = cd ?? null;
|
||||
}
|
||||
|
||||
const locationList = await db.select({ id: locations.id, name: locations.name }).from(locations);
|
||||
|
||||
return { device, computerDetails: compDetails, locations: locationList };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, params }) => {
|
||||
const formData = await request.formData();
|
||||
const raw = Object.fromEntries(formData);
|
||||
|
||||
const result = deviceSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error.errors[0]?.message ?? 'Invalid input', values: raw });
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const year = typeof data.year === 'number' ? data.year : null;
|
||||
const locationId = data.locationId || null;
|
||||
|
||||
await db
|
||||
.update(devices)
|
||||
.set({
|
||||
title: data.title,
|
||||
category: data.category,
|
||||
brand: data.brand || null,
|
||||
model: data.model || null,
|
||||
serialNumber: data.serialNumber || null,
|
||||
year,
|
||||
condition: data.condition,
|
||||
voltage: data.voltage || null,
|
||||
frequency: data.frequency || null,
|
||||
origin: data.origin || null,
|
||||
faultDescription: data.faultDescription || null,
|
||||
repairNotes: data.repairNotes || null,
|
||||
locationId,
|
||||
generalNotes: data.generalNotes || null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(devices.id, params.id));
|
||||
|
||||
// Upsert computer details
|
||||
if (data.category === 'Computer') {
|
||||
const [existing] = await db.select().from(computerDetails).where(eq(computerDetails.deviceId, params.id));
|
||||
if (existing) {
|
||||
await db
|
||||
.update(computerDetails)
|
||||
.set({
|
||||
osVersion: data.osVersion || null,
|
||||
firmwareVersion: data.firmwareVersion || null,
|
||||
installedSoftware: data.installedSoftware || null
|
||||
})
|
||||
.where(eq(computerDetails.deviceId, params.id));
|
||||
} else {
|
||||
await db.insert(computerDetails).values({
|
||||
deviceId: params.id,
|
||||
osVersion: data.osVersion || null,
|
||||
firmwareVersion: data.firmwareVersion || null,
|
||||
installedSoftware: data.installedSoftware || null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
redirect(303, `/devices/${params.id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { DEVICE_CATEGORIES, DEVICE_CONDITIONS, VOLTAGE_OPTIONS, FREQUENCY_OPTIONS } from '$lib/constants.js';
|
||||
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const d = data.device;
|
||||
const cd = data.computerDetails;
|
||||
let category = $state(form?.values?.category ?? d.category);
|
||||
let brand = $state(String(form?.values?.brand ?? d.brand ?? ''));
|
||||
let model = $state(String(form?.values?.model ?? d.model ?? ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Edit {d.title} - B4L Repair</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Edit {d.title}</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance class="space-y-6">
|
||||
<!-- Identity -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Identity</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
|
||||
<input type="text" id="title" name="title" required value={form?.values?.title ?? d.title}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="category" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Category *</label>
|
||||
<select id="category" name="category" bind:value={category}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
{#each DEVICE_CATEGORIES as cat}
|
||||
<option value={cat}>{cat}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="condition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition *</label>
|
||||
<select id="condition" name="condition"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
{#each DEVICE_CONDITIONS as cond}
|
||||
<option value={cond} selected={cond === (form?.values?.condition ?? d.condition)}>{cond}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="brand" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Brand</label>
|
||||
<AutocompleteInput id="brand" name="brand" bind:value={brand} placeholder="e.g. Apple"
|
||||
fetchUrl="/api/devices/brands" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="model" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
|
||||
<AutocompleteInput id="model" name="model" bind:value={model} placeholder="e.g. Color Classic"
|
||||
fetchUrl="/api/devices/models" extraParams={brand ? { brand: String(brand) } : {}} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="serialNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Serial Number</label>
|
||||
<input type="text" id="serialNumber" name="serialNumber" value={form?.values?.serialNumber ?? d.serialNumber ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical Specs -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Technical Specs</h2>
|
||||
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="year" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Year</label>
|
||||
<input type="number" id="year" name="year" value={form?.values?.year ?? d.year ?? ''} min="1900" max="2100"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage</label>
|
||||
<select id="voltage" name="voltage"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">—</option>
|
||||
{#each VOLTAGE_OPTIONS as v}
|
||||
<option value={v} selected={v === (form?.values?.voltage ?? d.voltage)}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="frequency" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Frequency</label>
|
||||
<select id="frequency" name="frequency"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">—</option>
|
||||
{#each FREQUENCY_OPTIONS as f}
|
||||
<option value={f} selected={f === (form?.values?.frequency ?? d.frequency)}>{f}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="origin" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Origin</label>
|
||||
<input type="text" id="origin" name="origin" value={form?.values?.origin ?? d.origin ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Computer Details -->
|
||||
{#if category === 'Computer'}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Computer Details</h2>
|
||||
<div class="mb-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="osVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">OS Version</label>
|
||||
<input type="text" id="osVersion" name="osVersion" value={form?.values?.osVersion ?? cd?.osVersion ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="firmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware / ROM</label>
|
||||
<input type="text" id="firmwareVersion" name="firmwareVersion" value={form?.values?.firmwareVersion ?? cd?.firmwareVersion ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="installedSoftware" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Installed Software</label>
|
||||
<textarea id="installedSoftware" name="installedSoftware" rows="3"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{form?.values?.installedSoftware ?? cd?.installedSoftware ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Condition & Repair -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Condition & Repair</h2>
|
||||
<div class="mb-4">
|
||||
<label for="faultDescription" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Fault Description</label>
|
||||
<textarea id="faultDescription" name="faultDescription" rows="3"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{form?.values?.faultDescription ?? d.faultDescription ?? ''}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="repairNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Repair Notes</label>
|
||||
<textarea id="repairNotes" name="repairNotes" rows="3"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{form?.values?.repairNotes ?? d.repairNotes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location & Notes -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Location & Notes</h2>
|
||||
<div class="mb-4">
|
||||
<label for="locationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
|
||||
<select id="locationId" name="locationId"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">No location</option>
|
||||
{#each data.locations as loc}
|
||||
<option value={loc.id} selected={loc.id === (form?.values?.locationId ?? d.locationId)}>{loc.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="generalNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">General Notes</label>
|
||||
<textarea id="generalNotes" name="generalNotes" rows="3"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{form?.values?.generalNotes ?? d.generalNotes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save Changes</button>
|
||||
<a href="/devices/{d.id}" class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { devices } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { generateQrSvg } from '$lib/server/qr.js';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const [device] = await db
|
||||
.select({
|
||||
id: devices.id,
|
||||
title: devices.title,
|
||||
brand: devices.brand,
|
||||
model: devices.model,
|
||||
serialNumber: devices.serialNumber,
|
||||
category: devices.category
|
||||
})
|
||||
.from(devices)
|
||||
.where(eq(devices.id, params.id));
|
||||
|
||||
if (!device) error(404, 'Device not found');
|
||||
|
||||
const url = `${env.BASE_URL ?? 'http://localhost:5173'}/devices/${params.id}`;
|
||||
const qrSvg = await generateQrSvg(url);
|
||||
|
||||
return { device, qrSvg, deviceUrl: url };
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Label - {data.device.title}</title>
|
||||
<style>
|
||||
@media print {
|
||||
nav, header, aside, button, .no-print { display: none !important; }
|
||||
main { padding: 0 !important; }
|
||||
body { background: white !important; }
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="no-print mb-4 flex items-center justify-between">
|
||||
<a href="/devices/{data.device.id}" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Back to device</a>
|
||||
<button onclick={() => window.print()} class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Print Label
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="rounded-lg border-2 border-dashed border-gray-300 bg-white p-6 dark:border-gray-600 dark:bg-gray-800">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
{@html data.qrSvg}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-bold text-gray-900 dark:text-white">{data.device.title}</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{[data.device.brand, data.device.model].filter(Boolean).join(' ') || data.device.category}
|
||||
</p>
|
||||
{#if data.device.serialNumber}
|
||||
<p class="mt-1 font-mono text-xs text-gray-500 dark:text-gray-400">S/N: {data.device.serialNumber}</p>
|
||||
{/if}
|
||||
<p class="mt-2 font-mono text-xs text-gray-400 dark:text-gray-500">{data.device.id.slice(0, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple labels for sheet printing -->
|
||||
<div class="no-print mt-6">
|
||||
<h3 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Small labels (cut along lines)</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each [0, 1, 2, 3] as _}
|
||||
<div class="rounded border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-16 w-16 flex-shrink-0">
|
||||
{@html data.qrSvg}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-bold text-gray-900 dark:text-white">{data.device.title}</p>
|
||||
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{[data.device.brand, data.device.model].filter(Boolean).join(' ')}
|
||||
</p>
|
||||
{#if data.device.serialNumber}
|
||||
<p class="truncate font-mono text-xs text-gray-400">{data.device.serialNumber}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { generateQrSvg } from '$lib/server/qr.js';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const url = `${env.BASE_URL ?? 'http://localhost:5173'}/devices/${params.id}`;
|
||||
const svg = await generateQrSvg(url);
|
||||
|
||||
return new Response(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=86400'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { devices, computerDetails, locations, deviceLog } from '$lib/server/db/schema.js';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
const deviceSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
category: z.enum(['Computer', 'Audio Equipment', 'Peripheral', 'Other']),
|
||||
brand: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
serialNumber: z.string().optional(),
|
||||
year: z.coerce.number().int().min(1900).max(2100).optional().or(z.literal('')),
|
||||
condition: z.enum(['Working', 'In Repair', 'Waiting for Repair', 'Waiting to be Tested', 'Unrepairable']),
|
||||
voltage: z.string().optional(),
|
||||
frequency: z.string().optional(),
|
||||
origin: z.string().optional(),
|
||||
initialCondition: z.string().optional(),
|
||||
faultDescription: z.string().optional(),
|
||||
repairNotes: z.string().optional(),
|
||||
locationId: z.string().uuid().optional().or(z.literal('')),
|
||||
generalNotes: z.string().optional(),
|
||||
// Computer-specific
|
||||
osVersion: z.string().optional(),
|
||||
firmwareVersion: z.string().optional(),
|
||||
installedSoftware: z.string().optional()
|
||||
});
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const locationList = await db.select({ id: locations.id, name: locations.name }).from(locations);
|
||||
return { locations: locationList };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const raw = Object.fromEntries(formData);
|
||||
|
||||
const result = deviceSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error.errors[0]?.message ?? 'Invalid input', values: raw });
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const year = typeof data.year === 'number' ? data.year : null;
|
||||
const locationId = data.locationId || null;
|
||||
|
||||
const [device] = await db
|
||||
.insert(devices)
|
||||
.values({
|
||||
title: data.title,
|
||||
category: data.category,
|
||||
brand: data.brand || null,
|
||||
model: data.model || null,
|
||||
serialNumber: data.serialNumber || null,
|
||||
year,
|
||||
condition: data.condition,
|
||||
voltage: data.voltage || null,
|
||||
frequency: data.frequency || null,
|
||||
origin: data.origin || null,
|
||||
initialCondition: data.initialCondition || null,
|
||||
faultDescription: data.faultDescription || null,
|
||||
repairNotes: data.repairNotes || null,
|
||||
locationId,
|
||||
generalNotes: data.generalNotes || null
|
||||
})
|
||||
.returning({ id: devices.id });
|
||||
|
||||
// If computer, insert computer details
|
||||
if (data.category === 'Computer' && device) {
|
||||
await db.insert(computerDetails).values({
|
||||
deviceId: device.id,
|
||||
osVersion: data.osVersion || null,
|
||||
firmwareVersion: data.firmwareVersion || null,
|
||||
installedSoftware: data.installedSoftware || null
|
||||
});
|
||||
}
|
||||
|
||||
// Create initial log entry if initial condition was provided
|
||||
if (data.initialCondition && device) {
|
||||
await db.insert(deviceLog).values({
|
||||
deviceId: device.id,
|
||||
type: 'inspection',
|
||||
description: `Intake: ${data.initialCondition}`,
|
||||
conditionAfter: data.condition,
|
||||
performedBy: null
|
||||
});
|
||||
}
|
||||
|
||||
redirect(303, `/devices/${device!.id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { DEVICE_CATEGORIES, DEVICE_CONDITIONS, VOLTAGE_OPTIONS, FREQUENCY_OPTIONS } from '$lib/constants.js';
|
||||
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let category = $state(form?.values?.category ?? 'Computer');
|
||||
let brand = $state(String(form?.values?.brand ?? ''));
|
||||
let model = $state(String(form?.values?.model ?? ''));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Add Device - B4L Repair</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Add Device</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance class="space-y-6">
|
||||
<!-- Identity -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Identity</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
|
||||
<input type="text" id="title" name="title" required value={form?.values?.title ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="e.g. Color Classic #5" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="category" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Category *</label>
|
||||
<select id="category" name="category" bind:value={category}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
{#each DEVICE_CATEGORIES as cat}
|
||||
<option value={cat}>{cat}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="condition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Condition *</label>
|
||||
<select id="condition" name="condition"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
{#each DEVICE_CONDITIONS as cond}
|
||||
<option value={cond} selected={cond === (form?.values?.condition ?? 'Waiting to be Tested')}>{cond}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="brand" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Brand</label>
|
||||
<AutocompleteInput id="brand" name="brand" bind:value={brand} placeholder="e.g. Apple"
|
||||
fetchUrl="/api/devices/brands" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="model" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
|
||||
<AutocompleteInput id="model" name="model" bind:value={model} placeholder="e.g. Color Classic"
|
||||
fetchUrl="/api/devices/models" extraParams={brand ? { brand: String(brand) } : {}} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="serialNumber" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Serial Number</label>
|
||||
<input type="text" id="serialNumber" name="serialNumber" value={form?.values?.serialNumber ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical Specs -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Technical Specs</h2>
|
||||
|
||||
<div class="mb-4 grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="year" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Year</label>
|
||||
<input type="number" id="year" name="year" value={form?.values?.year ?? ''} min="1900" max="2100"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage</label>
|
||||
<select id="voltage" name="voltage"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">—</option>
|
||||
{#each VOLTAGE_OPTIONS as v}
|
||||
<option value={v} selected={form?.values?.voltage === v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="frequency" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Frequency</label>
|
||||
<select id="frequency" name="frequency"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">—</option>
|
||||
{#each FREQUENCY_OPTIONS as f}
|
||||
<option value={f} selected={form?.values?.frequency === f}>{f}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="origin" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Origin</label>
|
||||
<input type="text" id="origin" name="origin" value={form?.values?.origin ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="e.g. Japan" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Computer Details (conditional) -->
|
||||
{#if category === 'Computer'}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Computer Details</h2>
|
||||
|
||||
<div class="mb-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="osVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">OS Version</label>
|
||||
<input type="text" id="osVersion" name="osVersion" value={form?.values?.osVersion ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="e.g. System 7.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="firmwareVersion" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Firmware / ROM Version</label>
|
||||
<input type="text" id="firmwareVersion" name="firmwareVersion" value={form?.values?.firmwareVersion ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="installedSoftware" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Installed Software</label>
|
||||
<textarea id="installedSoftware" name="installedSoftware" rows="3"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="Notable software installed...">{form?.values?.installedSoftware ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Initial Condition -->
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-5 dark:border-blue-800 dark:bg-blue-900/10">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-blue-500 dark:text-blue-400">Condition on Intake</h2>
|
||||
|
||||
<div>
|
||||
<label for="initialCondition" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Describe the condition when entering the system</label>
|
||||
<textarea id="initialCondition" name="initialCondition" rows="4"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="e.g. Cosmetically good, screen has slight burn-in, powers on but no chime, missing PRAM battery, HDD clicks on spin-up...">{form?.values?.initialCondition ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Condition & Repair -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Condition & Repair</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="faultDescription" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Fault Description</label>
|
||||
<textarea id="faultDescription" name="faultDescription" rows="3"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="Describe any known faults...">{form?.values?.faultDescription ?? ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="repairNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Repair Notes</label>
|
||||
<textarea id="repairNotes" name="repairNotes" rows="3"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="Repair history and notes...">{form?.values?.repairNotes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location & Notes -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Location & Notes</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="locationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
|
||||
<select id="locationId" name="locationId"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">No location</option>
|
||||
{#each data.locations as loc}
|
||||
<option value={loc.id} selected={form?.values?.locationId === loc.id}>{loc.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="generalNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">General Notes</label>
|
||||
<textarea id="generalNotes" name="generalNotes" rows="3"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400">{form?.values?.generalNotes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Create Device
|
||||
</button>
|
||||
<a href="/devices" class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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}· {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 };
|
||||
}
|
||||
};
|
||||
@@ -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">└ </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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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)}`);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
precompress: true
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user