Add Parts section: consumable stock inventory (caps, resistors, etc.)
Deploy to LXC / deploy (push) Failing after 13m44s

- New parts table with category, MPN, value, voltage, tolerance, power, mounting (THT/SMT), package, quantity, location
- /parts list (table view, category + mounting filters, colour-coded stock)
- /parts/new and /parts/[id]/edit with dynamic fields per category
- /parts/[id] detail with ± stock adjustment
- Sidebar nav entry and count badge
- Drizzle migration generated (run db:push to apply)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:32:21 +07:00
co-authored by Claude Sonnet 4.6
parent ba32984a52
commit dc9217f3b6
15 changed files with 3661 additions and 3 deletions
+7 -1
View File
@@ -4,7 +4,7 @@
interface Props {
open: boolean;
onToggle: () => void;
counts: { devices: number; components: number; needsRepair: number; openTodos: number };
counts: { devices: number; components: number; parts: number; needsRepair: number; openTodos: number };
}
let { open, onToggle, counts }: Props = $props();
@@ -27,6 +27,12 @@
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: '/parts',
label: 'Parts',
badge: counts.parts || undefined,
icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4'
},
{
href: '/installations',
label: 'Install Log',
+17
View File
@@ -63,6 +63,23 @@ export const TODO_PRIORITIES = [
{ value: 3, label: 'Low', color: 'gray' }
] as const;
export const PART_CATEGORIES = [
'Capacitor',
'Resistor',
'Transistor',
'IC / Chip',
'Diode',
'LED',
'Button / Switch',
'Connector',
'Fuse',
'Inductor',
'Crystal / Oscillator',
'Other'
] as const;
export type PartCategory = (typeof PART_CATEGORIES)[number];
export const VOLTAGE_OPTIONS = [
'100-120V AC',
'220-240V AC',
+28
View File
@@ -373,6 +373,34 @@ export const featureRequests = pgTable('feature_requests', {
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
// ─── Parts (consumable stock, no per-device tracking) ───────────
export const parts = pgTable(
'parts',
{
id: uuid('id').defaultRandom().primaryKey(),
name: text('name'),
category: text('category').notNull(),
mpn: text('mpn'),
value: text('value'),
voltage: text('voltage'),
tolerance: text('tolerance'),
power: text('power'),
mounting: text('mounting'),
package: text('package'),
quantity: integer('quantity').notNull().default(0),
unit: text('unit').notNull().default('pcs'),
locationId: uuid('location_id').references(() => locations.id),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('parts_category_idx').on(table.category),
index('parts_location_idx').on(table.locationId)
]
);
// ─── Wiki ───────────────────────────────────────────────────────────
export const wikiCategories = pgTable('wiki_categories', {
+5 -2
View File
@@ -1,7 +1,7 @@
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { devices, components, componentInstances, todos } from '$lib/server/db/schema.js';
import { devices, components, componentInstances, todos, parts } from '$lib/server/db/schema.js';
import { count, eq, or, and, ne } from 'drizzle-orm';
export const load: LayoutServerLoad = async ({ locals }) => {
@@ -25,13 +25,16 @@ export const load: LayoutServerLoad = async ({ locals }) => {
.from(todos)
.where(ne(todos.status, 'done'));
const [partsCount] = await db.select({ value: count() }).from(parts);
return {
user: locals.user,
counts: {
devices: deviceCount?.value ?? 0,
components: componentCount?.value ?? 0,
needsRepair: repairCount?.value ?? 0,
openTodos: openTodos?.value ?? 0
openTodos: openTodos?.value ?? 0,
parts: partsCount?.value ?? 0
}
};
};
+61
View File
@@ -0,0 +1,61 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { parts, locations } from '$lib/server/db/schema.js';
import { eq, ilike, or, and, count } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url }) => {
const category = url.searchParams.get('category');
const mounting = url.searchParams.get('mounting');
const search = url.searchParams.get('q');
const page = Math.max(1, Number(url.searchParams.get('page') ?? 1));
const pageSize = 48;
const conditions = [];
if (category) conditions.push(eq(parts.category, category));
if (mounting) conditions.push(eq(parts.mounting, mounting));
if (search) {
conditions.push(
or(
ilike(parts.name, `%${search}%`),
ilike(parts.mpn, `%${search}%`),
ilike(parts.value, `%${search}%`),
ilike(parts.package, `%${search}%`)
)!
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db.select({ value: count() }).from(parts).where(where);
const partList = await db
.select({
id: parts.id,
name: parts.name,
category: parts.category,
mpn: parts.mpn,
value: parts.value,
voltage: parts.voltage,
tolerance: parts.tolerance,
power: parts.power,
mounting: parts.mounting,
package: parts.package,
quantity: parts.quantity,
unit: parts.unit,
locationId: parts.locationId
})
.from(parts)
.leftJoin(locations, eq(parts.locationId, locations.id))
.where(where)
.orderBy(parts.category, parts.value)
.limit(pageSize)
.offset((page - 1) * pageSize);
return {
parts: partList,
total: totalResult?.value ?? 0,
page,
pageSize,
filters: { category, mounting, search }
};
};
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { PART_CATEGORIES } 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));
function mountingColor(mounting: string | null) {
if (mounting === 'SMT') return 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300';
if (mounting === 'THT') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
}
function stockColor(qty: number) {
if (qty <= 0) return 'text-red-600 dark:text-red-400';
if (qty <= 5) return 'text-amber-600 dark:text-amber-400';
return 'text-green-600 dark:text-green-400';
}
</script>
<svelte:head>
<title>Parts - My Collection</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">Parts</h1>
<a href="/parts/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Add Part
</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 by value, MPN, package..."
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 PART_CATEGORIES as c}
<option value={c} selected={data.filters.category === c}>{c}</option>
{/each}
</select>
<select onchange={(e) => applyFilter('mounting', (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="">THT + SMT</option>
<option value="THT" selected={data.filters.mounting === 'THT'}>THT</option>
<option value="SMT" selected={data.filters.mounting === 'SMT'}>SMT</option>
</select>
</div>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">{data.total} part{data.total !== 1 ? 's' : ''}</p>
{#if data.parts.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 parts found.</p>
<a href="/parts/new" class="mt-2 inline-block text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">Add your first part</a>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Category</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Value / MPN</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Package</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Specs</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Stock</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.parts as part}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-3">
<a href="/parts/{part.id}" class="block">
<span class="text-sm text-gray-700 dark:text-gray-300">{part.category}</span>
</a>
</td>
<td class="px-4 py-3">
<a href="/parts/{part.id}" class="block">
{#if part.value}
<span class="font-medium text-gray-900 dark:text-white">{part.value}</span>
{/if}
{#if part.name}
<span class="block text-xs text-gray-500 dark:text-gray-400">{part.name}</span>
{/if}
{#if part.mpn}
<span class="block text-xs font-mono text-gray-400 dark:text-gray-500">{part.mpn}</span>
{/if}
</a>
</td>
<td class="px-4 py-3">
<a href="/parts/{part.id}" class="flex items-center gap-2">
{#if part.mounting}
<span class="rounded px-1.5 py-0.5 text-xs font-medium {mountingColor(part.mounting)}">{part.mounting}</span>
{/if}
{#if part.package}
<span class="text-sm text-gray-600 dark:text-gray-400">{part.package}</span>
{/if}
</a>
</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
<a href="/parts/{part.id}" class="block">
{[part.voltage, part.tolerance, part.power].filter(Boolean).join(' · ')}
</a>
</td>
<td class="px-4 py-3 text-right">
<a href="/parts/{part.id}" class="block">
<span class="text-sm font-medium {stockColor(part.quantity)}">{part.quantity}</span>
<span class="text-xs text-gray-400 dark:text-gray-500"> {part.unit}</span>
</a>
</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,59 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { parts, locations } from '$lib/server/db/schema.js';
import { eq, sql } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
export const load: PageServerLoad = async ({ params }) => {
const [part] = await db
.select({
id: parts.id,
name: parts.name,
category: parts.category,
mpn: parts.mpn,
value: parts.value,
voltage: parts.voltage,
tolerance: parts.tolerance,
power: parts.power,
mounting: parts.mounting,
package: parts.package,
quantity: parts.quantity,
unit: parts.unit,
locationId: parts.locationId,
locationName: locations.name,
notes: parts.notes,
createdAt: parts.createdAt,
updatedAt: parts.updatedAt
})
.from(parts)
.leftJoin(locations, eq(parts.locationId, locations.id))
.where(eq(parts.id, params.id));
if (!part) error(404, 'Part not found');
return { part };
};
export const actions: Actions = {
adjust: async ({ request, params }) => {
const formData = await request.formData();
const delta = z.coerce.number().int().safeParse(formData.get('delta'));
if (!delta.success) return fail(400, { error: 'Invalid quantity' });
await db
.update(parts)
.set({
quantity: sql`GREATEST(0, ${parts.quantity} + ${delta.data})`,
updatedAt: sql`now()`
})
.where(eq(parts.id, params.id));
return { success: true };
},
delete: async ({ params }) => {
await db.delete(parts).where(eq(parts.id, params.id));
redirect(303, '/parts');
}
};
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
const p = $derived(data.part);
function mountingColor(mounting: string | null) {
if (mounting === 'SMT') return 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300';
if (mounting === 'THT') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
return '';
}
function stockColor(qty: number) {
if (qty <= 0) return 'text-red-600 dark:text-red-400';
if (qty <= 5) return 'text-amber-600 dark:text-amber-400';
return 'text-green-600 dark:text-green-400';
}
</script>
<svelte:head>
<title>{p.value ?? p.name ?? p.category} - Parts</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<!-- Header -->
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400">{p.category}</p>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{p.value ?? p.name ?? p.mpn ?? 'Part'}
</h1>
{#if p.name && p.value}
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{p.name}</p>
{/if}
</div>
<div class="flex shrink-0 gap-2">
<a href="/parts/{p.id}/edit" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
Edit
</a>
<form method="POST" action="?/delete" use:enhance>
<button type="submit" onclick={(e) => { if (!confirm('Delete this part?')) e.preventDefault(); }}
class="rounded-md border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Delete
</button>
</form>
</div>
</div>
<!-- Stock card -->
<div class="mb-5 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">Stock</h2>
<div class="flex items-center gap-6">
<div class="text-4xl font-bold {stockColor(p.quantity)}">
{p.quantity}
<span class="text-lg font-normal text-gray-400 dark:text-gray-500">{p.unit}</span>
</div>
<div class="flex gap-2">
<form method="POST" action="?/adjust" use:enhance>
<input type="hidden" name="delta" value="-1" />
<button type="submit" class="flex h-9 w-9 items-center justify-center rounded-md border border-gray-300 text-lg font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"></button>
</form>
<form method="POST" action="?/adjust" use:enhance>
<input type="hidden" name="delta" value="1" />
<button type="submit" class="flex h-9 w-9 items-center justify-center rounded-md border border-gray-300 text-lg font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">+</button>
</form>
<form method="POST" action="?/adjust" use:enhance class="flex items-center gap-1">
<input type="number" name="delta" placeholder="±qty"
class="w-20 rounded-md border border-gray-300 px-2 py-1.5 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" />
<button type="submit" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Apply</button>
</form>
</div>
</div>
{#if p.locationName}
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">📍 {p.locationName}</p>
{/if}
</div>
<!-- Specs -->
<div class="mb-5 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">Specs</h2>
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-3">
{#if p.mpn}
<div class="col-span-2 sm:col-span-3">
<dt class="text-gray-500 dark:text-gray-400">MPN</dt>
<dd class="font-mono font-medium text-gray-900 dark:text-white">{p.mpn}</dd>
</div>
{/if}
{#if p.value}
<div>
<dt class="text-gray-500 dark:text-gray-400">Value</dt>
<dd class="font-medium text-gray-900 dark:text-white">{p.value}</dd>
</div>
{/if}
{#if p.voltage}
<div>
<dt class="text-gray-500 dark:text-gray-400">Voltage</dt>
<dd class="font-medium text-gray-900 dark:text-white">{p.voltage}</dd>
</div>
{/if}
{#if p.tolerance}
<div>
<dt class="text-gray-500 dark:text-gray-400">Tolerance</dt>
<dd class="font-medium text-gray-900 dark:text-white">{p.tolerance}</dd>
</div>
{/if}
{#if p.power}
<div>
<dt class="text-gray-500 dark:text-gray-400">Power</dt>
<dd class="font-medium text-gray-900 dark:text-white">{p.power}</dd>
</div>
{/if}
{#if p.mounting || p.package}
<div>
<dt class="text-gray-500 dark:text-gray-400">Package</dt>
<dd class="flex items-center gap-2">
{#if p.mounting}
<span class="rounded px-1.5 py-0.5 text-xs font-medium {mountingColor(p.mounting)}">{p.mounting}</span>
{/if}
{#if p.package}
<span class="font-medium text-gray-900 dark:text-white">{p.package}</span>
{/if}
</dd>
</div>
{/if}
</dl>
</div>
{#if p.notes}
<div class="mb-5 rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-2 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">{p.notes}</p>
</div>
{/if}
<div class="mt-2">
<a href="/parts" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← Back to Parts</a>
</div>
</div>
@@ -0,0 +1,74 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { parts, locations } from '$lib/server/db/schema.js';
import { eq, sql } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
const partSchema = z.object({
name: z.string().optional(),
category: z.string().min(1, 'Category is required'),
mpn: z.string().optional(),
value: z.string().optional(),
voltage: z.string().optional(),
tolerance: z.string().optional(),
power: z.string().optional(),
mounting: z.enum(['THT', 'SMT']).optional().or(z.literal('')),
package: z.string().optional(),
quantity: z.coerce.number().int().min(0).default(0),
unit: z.string().default('pcs'),
locationId: z.string().uuid().optional().or(z.literal('')),
notes: z.string().optional()
});
export const load: PageServerLoad = async ({ params }) => {
const [part] = await db
.select()
.from(parts)
.where(eq(parts.id, params.id));
if (!part) error(404, 'Part not found');
const locationList = await db
.select({ id: locations.id, name: locations.name, parentId: locations.parentId })
.from(locations)
.orderBy(locations.name);
return { part, locations: locationList };
};
export const actions: Actions = {
default: async ({ request, params }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
const result = partSchema.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(parts)
.set({
name: data.name || null,
category: data.category,
mpn: data.mpn || null,
value: data.value || null,
voltage: data.voltage || null,
tolerance: data.tolerance || null,
power: data.power || null,
mounting: data.mounting || null,
package: data.package || null,
quantity: data.quantity,
unit: data.unit,
locationId: data.locationId || null,
notes: data.notes || null,
updatedAt: sql`now()`
})
.where(eq(parts.id, params.id));
redirect(303, `/parts/${params.id}`);
}
};
@@ -0,0 +1,153 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { PART_CATEGORIES } from '$lib/constants.js';
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
let { data, form } = $props();
let category = $state(String(form?.values?.category ?? data.part.category ?? ''));
const showCapacitance = $derived(category === 'Capacitor');
const showResistance = $derived(category === 'Resistor');
const showVoltage = $derived(['Capacitor', 'Diode', 'LED', 'Transistor', 'IC / Chip', 'Fuse'].includes(category));
const showPower = $derived(['Resistor', 'Transistor', 'Diode', 'Inductor'].includes(category));
const showTolerance = $derived(['Resistor', 'Capacitor', 'Inductor', 'Crystal / Oscillator'].includes(category));
const val = (field: string) => (form?.values as any)?.[field] ?? (data.part as any)[field] ?? '';
</script>
<svelte:head>
<title>Edit Part - My Collection</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 Part</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-5">
<!-- 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 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" required 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 PART_CATEGORIES as c}
<option value={c} selected={category === c}>{c}</option>
{/each}
</select>
</div>
<div>
<label for="mpn" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">MPN</label>
<input type="text" id="mpn" name="mpn" value={val('mpn')}
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="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" id="name" name="name" value={val('name')}
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="grid gap-4 sm:grid-cols-2">
<div>
<label for="value" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{#if showCapacitance}Capacitance{:else if showResistance}Resistance{:else}Value{/if}
</label>
<input type="text" id="value" name="value" value={val('value')}
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>
{#if showVoltage}
<div>
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage Rating</label>
<input type="text" id="voltage" name="voltage" value={val('voltage')}
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>
{/if}
{#if showTolerance}
<div>
<label for="tolerance" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tolerance</label>
<input type="text" id="tolerance" name="tolerance" value={val('tolerance')}
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>
{/if}
{#if showPower}
<div>
<label for="power" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Power Rating</label>
<input type="text" id="power" name="power" value={val('power')}
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>
{/if}
</div>
</div>
<!-- Package -->
<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">Package</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<p class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Mounting</p>
<div class="flex gap-3">
{#each ['THT', 'SMT'] as m}
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="radio" name="mounting" value={m} checked={val('mounting') === m}
class="text-blue-600 focus:ring-blue-500" />
{m}
</label>
{/each}
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<input type="radio" name="mounting" value="" checked={!val('mounting')}
class="text-blue-600 focus:ring-blue-500" />
Unknown
</label>
</div>
</div>
<div>
<label for="package" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Package / Size</label>
<input type="text" id="package" name="package" value={val('package')}
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>
<!-- Stock -->
<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">Stock</h2>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="quantity" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Quantity</label>
<input type="number" id="quantity" name="quantity" value={data.part.quantity} min="0"
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="unit" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Unit</label>
<input type="text" id="unit" name="unit" value={data.part.unit}
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>
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage Location</span>
<LocationPicker locations={data.locations} name="locationId" value={data.part.locationId ?? null} />
</div>
</div>
<!-- Notes -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<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">{val('notes')}</textarea>
</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="/parts/{data.part.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,64 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { parts, locations } from '$lib/server/db/schema.js';
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
const partSchema = z.object({
name: z.string().optional(),
category: z.string().min(1, 'Category is required'),
mpn: z.string().optional(),
value: z.string().optional(),
voltage: z.string().optional(),
tolerance: z.string().optional(),
power: z.string().optional(),
mounting: z.enum(['THT', 'SMT']).optional().or(z.literal('')),
package: z.string().optional(),
quantity: z.coerce.number().int().min(0).default(0),
unit: z.string().default('pcs'),
locationId: z.string().uuid().optional().or(z.literal('')),
notes: z.string().optional()
});
export const load: PageServerLoad = async () => {
const locationList = await db
.select({ id: locations.id, name: locations.name, parentId: locations.parentId })
.from(locations)
.orderBy(locations.name);
return { locations: locationList };
};
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
const result = partSchema.safeParse(raw);
if (!result.success) {
return fail(400, { error: result.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const data = result.data;
const [part] = await db
.insert(parts)
.values({
name: data.name || null,
category: data.category,
mpn: data.mpn || null,
value: data.value || null,
voltage: data.voltage || null,
tolerance: data.tolerance || null,
power: data.power || null,
mounting: data.mounting || null,
package: data.package || null,
quantity: data.quantity,
unit: data.unit,
locationId: data.locationId || null,
notes: data.notes || null
})
.returning({ id: parts.id });
redirect(303, `/parts/${part!.id}`);
}
};
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { PART_CATEGORIES } from '$lib/constants.js';
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
let { data, form } = $props();
let category = $state(String(form?.values?.category ?? ''));
const showCapacitance = $derived(category === 'Capacitor');
const showResistance = $derived(category === 'Resistor');
const showVoltage = $derived(['Capacitor', 'Diode', 'LED', 'Transistor', 'IC / Chip', 'Fuse'].includes(category));
const showPower = $derived(['Resistor', 'Transistor', 'Diode', 'Inductor'].includes(category));
const showTolerance = $derived(['Resistor', 'Capacitor', 'Inductor', 'Crystal / Oscillator'].includes(category));
</script>
<svelte:head>
<title>Add Part - My Collection</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 Part</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-5">
<!-- 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 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" required 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">
<option value="">Select category…</option>
{#each PART_CATEGORIES as c}
<option value={c}>{c}</option>
{/each}
</select>
</div>
<div>
<label for="mpn" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">MPN</label>
<input type="text" id="mpn" name="mpn" value={form?.values?.mpn ?? ''}
placeholder="Manufacturer part number"
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 class="mb-4">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" id="name" name="name" value={form?.values?.name ?? ''}
placeholder="e.g. Nichicon electrolytic, BC547 NPN"
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 class="grid gap-4 sm:grid-cols-2">
<div>
<label for="value" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{#if showCapacitance}Capacitance{:else if showResistance}Resistance{:else}Value{/if}
</label>
<input type="text" id="value" name="value" value={form?.values?.value ?? ''}
placeholder={showCapacitance ? 'e.g. 100µF' : showResistance ? 'e.g. 10kΩ' : 'e.g. 1N4148'}
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>
{#if showVoltage}
<div>
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage Rating</label>
<input type="text" id="voltage" name="voltage" value={form?.values?.voltage ?? ''}
placeholder="e.g. 16V, 50V"
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>
{/if}
{#if showTolerance}
<div>
<label for="tolerance" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tolerance</label>
<input type="text" id="tolerance" name="tolerance" value={form?.values?.tolerance ?? ''}
placeholder="e.g. 5%, 10%, 20%"
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>
{/if}
{#if showPower}
<div>
<label for="power" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Power Rating</label>
<input type="text" id="power" name="power" value={form?.values?.power ?? ''}
placeholder="e.g. 0.25W, 1W"
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>
{/if}
</div>
</div>
<!-- Package -->
<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">Package</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<p class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Mounting</p>
<div class="flex gap-3">
{#each ['THT', 'SMT'] as m}
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="radio" name="mounting" value={m} checked={form?.values?.mounting === m}
class="text-blue-600 focus:ring-blue-500" />
{m}
</label>
{/each}
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<input type="radio" name="mounting" value="" checked={!form?.values?.mounting}
class="text-blue-600 focus:ring-blue-500" />
Unknown
</label>
</div>
</div>
<div>
<label for="package" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Package / Size</label>
<input type="text" id="package" name="package" value={form?.values?.package ?? ''}
placeholder="e.g. 0805, DIP-8, TO-92, Radial 5mm"
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>
<!-- Stock -->
<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">Stock</h2>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="quantity" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Quantity</label>
<input type="number" id="quantity" name="quantity" value={form?.values?.quantity ?? 0} min="0"
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="unit" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Unit</label>
<input type="text" id="unit" name="unit" value={form?.values?.unit ?? 'pcs'}
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>
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage Location</span>
<LocationPicker locations={data.locations} name="locationId" />
</div>
</div>
<!-- Notes -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<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">{form?.values?.notes ?? ''}</textarea>
</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">Add Part</button>
<a href="/parts" 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>