Add Parts section: consumable stock inventory (caps, resistors, etc.)
Deploy to LXC / deploy (push) Failing after 13m44s
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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user