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
@@ -0,0 +1,327 @@
CREATE TABLE "checklist_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"checklist_id" uuid NOT NULL,
"text" text NOT NULL,
"item_type" text DEFAULT 'checkbox' NOT NULL,
"unit" text,
"checked" boolean DEFAULT false NOT NULL,
"value" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "checklist_templates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "component_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"component_id" uuid NOT NULL,
"file_path" text NOT NULL,
"original_filename" text NOT NULL,
"file_type" text,
"description" text,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "component_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"component_id" uuid NOT NULL,
"file_path" text NOT NULL,
"thumbnail_path" text,
"caption" text,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "component_instances" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"component_id" uuid NOT NULL,
"instance_number" integer DEFAULT 1 NOT NULL,
"serial_number" text,
"condition" text DEFAULT 'Working' NOT NULL,
"firmware_version" text,
"notes" text,
"current_device_id" uuid,
"location_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "instances_condition_check" CHECK ("component_instances"."condition" IN ('Working', 'Faulty', 'Unknown', 'Refurbished'))
);
--> statement-breakpoint
CREATE TABLE "components" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"component_type" text NOT NULL,
"brand" text,
"part_number" text,
"specs" text,
"notes" text,
"default_condition" text DEFAULT 'Working' NOT NULL,
"default_firmware_version" text,
"default_location_id" uuid,
"disabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "computer_details" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"os_version" text,
"firmware_version" text,
"installed_software" text,
CONSTRAINT "computer_details_device_id_unique" UNIQUE("device_id")
);
--> statement-breakpoint
CREATE TABLE "device_checklists" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"title" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "device_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"file_path" text NOT NULL,
"original_filename" text NOT NULL,
"file_type" text,
"description" text,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "device_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"file_path" text NOT NULL,
"thumbnail_path" text,
"caption" text,
"sort_order" integer DEFAULT 0,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "device_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"type" text NOT NULL,
"description" text NOT NULL,
"condition_after" text,
"performed_by" text,
"performed_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "device_log_type_check" CHECK ("device_log"."type" IN ('repair', 'inspection', 'cleaning', 'modification', 'diagnostic', 'recap', 'other'))
);
--> statement-breakpoint
CREATE TABLE "device_tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"tag_uid" text,
"payload_url" text NOT NULL,
"written_by" text,
"written_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "devices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"category" text NOT NULL,
"brand" text,
"model" text,
"serial_number" text,
"year" integer,
"condition" text DEFAULT 'Waiting to be Tested' NOT NULL,
"voltage" text,
"frequency" text,
"origin" text,
"fault_description" text,
"repair_notes" text,
"location_id" uuid,
"initial_condition" text,
"general_notes" text,
"disabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "devices_category_check" CHECK ("devices"."category" IN ('Computer', 'Audio Equipment', 'Peripheral', 'Other')),
CONSTRAINT "devices_condition_check" CHECK ("devices"."condition" IN ('Working', 'In Repair', 'Waiting for Repair', 'Waiting to be Tested', 'Unrepairable'))
);
--> statement-breakpoint
CREATE TABLE "feature_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text,
"status" text DEFAULT 'open' NOT NULL,
"votes" integer DEFAULT 0 NOT NULL,
"created_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "installation_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"instance_id" uuid NOT NULL,
"device_id" uuid NOT NULL,
"action" text NOT NULL,
"performed_by" text,
"notes" text,
"performed_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "install_log_action_check" CHECK ("installation_log"."action" IN ('installed', 'removed', 'swapped'))
);
--> statement-breakpoint
CREATE TABLE "locations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"parent_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "parts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text,
"category" text NOT NULL,
"mpn" text,
"value" text,
"voltage" text,
"tolerance" text,
"power" text,
"mounting" text,
"package" text,
"quantity" integer DEFAULT 0 NOT NULL,
"unit" text DEFAULT 'pcs' NOT NULL,
"location_id" uuid,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "template_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"template_id" uuid NOT NULL,
"text" text NOT NULL,
"item_type" text DEFAULT 'checkbox' NOT NULL,
"unit" text,
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "todos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text,
"status" text DEFAULT 'todo' NOT NULL,
"priority" integer DEFAULT 2 NOT NULL,
"device_id" uuid,
"due_date" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "todos_status_check" CHECK ("todos"."status" IN ('todo', 'in_progress', 'done')),
CONSTRAINT "todos_priority_check" CHECK ("todos"."priority" IN (0, 1, 2, 3))
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"display_name" text,
"password_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "wiki_categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"description" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "wiki_categories_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "wiki_page_tags" (
"page_id" uuid NOT NULL,
"tag_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wiki_pages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"slug" text NOT NULL,
"content" text NOT NULL,
"category_id" uuid,
"created_by" text,
"updated_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "wiki_pages_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "wiki_tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
CONSTRAINT "wiki_tags_name_unique" UNIQUE("name")
);
--> statement-breakpoint
ALTER TABLE "checklist_items" ADD CONSTRAINT "checklist_items_checklist_id_device_checklists_id_fk" FOREIGN KEY ("checklist_id") REFERENCES "public"."device_checklists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "component_documents" ADD CONSTRAINT "component_documents_component_id_components_id_fk" FOREIGN KEY ("component_id") REFERENCES "public"."components"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "component_images" ADD CONSTRAINT "component_images_component_id_components_id_fk" FOREIGN KEY ("component_id") REFERENCES "public"."components"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "component_instances" ADD CONSTRAINT "component_instances_component_id_components_id_fk" FOREIGN KEY ("component_id") REFERENCES "public"."components"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "component_instances" ADD CONSTRAINT "component_instances_current_device_id_devices_id_fk" FOREIGN KEY ("current_device_id") REFERENCES "public"."devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "component_instances" ADD CONSTRAINT "component_instances_location_id_locations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."locations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "components" ADD CONSTRAINT "components_default_location_id_locations_id_fk" FOREIGN KEY ("default_location_id") REFERENCES "public"."locations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "computer_details" ADD CONSTRAINT "computer_details_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "device_checklists" ADD CONSTRAINT "device_checklists_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "device_documents" ADD CONSTRAINT "device_documents_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "device_images" ADD CONSTRAINT "device_images_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "device_log" ADD CONSTRAINT "device_log_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "device_tags" ADD CONSTRAINT "device_tags_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "devices" ADD CONSTRAINT "devices_location_id_locations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."locations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "installation_log" ADD CONSTRAINT "installation_log_instance_id_component_instances_id_fk" FOREIGN KEY ("instance_id") REFERENCES "public"."component_instances"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "installation_log" ADD CONSTRAINT "installation_log_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "locations" ADD CONSTRAINT "locations_parent_id_locations_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."locations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "parts" ADD CONSTRAINT "parts_location_id_locations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."locations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "template_items" ADD CONSTRAINT "template_items_template_id_checklist_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."checklist_templates"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "todos" ADD CONSTRAINT "todos_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_page_tags" ADD CONSTRAINT "wiki_page_tags_page_id_wiki_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_page_tags" ADD CONSTRAINT "wiki_page_tags_tag_id_wiki_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."wiki_tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_category_id_wiki_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."wiki_categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "checklist_items_checklist_idx" ON "checklist_items" USING btree ("checklist_id");--> statement-breakpoint
CREATE INDEX "component_documents_component_idx" ON "component_documents" USING btree ("component_id");--> statement-breakpoint
CREATE INDEX "component_images_component_idx" ON "component_images" USING btree ("component_id");--> statement-breakpoint
CREATE INDEX "instances_component_idx" ON "component_instances" USING btree ("component_id");--> statement-breakpoint
CREATE INDEX "instances_device_idx" ON "component_instances" USING btree ("current_device_id");--> statement-breakpoint
CREATE INDEX "instances_location_idx" ON "component_instances" USING btree ("location_id");--> statement-breakpoint
CREATE INDEX "components_type_idx" ON "components" USING btree ("component_type");--> statement-breakpoint
CREATE INDEX "device_checklists_device_idx" ON "device_checklists" USING btree ("device_id");--> statement-breakpoint
CREATE INDEX "device_documents_device_idx" ON "device_documents" USING btree ("device_id");--> statement-breakpoint
CREATE INDEX "device_images_device_idx" ON "device_images" USING btree ("device_id");--> statement-breakpoint
CREATE INDEX "device_log_device_idx" ON "device_log" USING btree ("device_id");--> statement-breakpoint
CREATE INDEX "device_log_date_idx" ON "device_log" USING btree ("performed_at");--> statement-breakpoint
CREATE INDEX "device_tags_device_idx" ON "device_tags" USING btree ("device_id");--> statement-breakpoint
CREATE INDEX "device_tags_written_at_idx" ON "device_tags" USING btree ("written_at");--> statement-breakpoint
CREATE INDEX "devices_category_idx" ON "devices" USING btree ("category");--> statement-breakpoint
CREATE INDEX "devices_condition_idx" ON "devices" USING btree ("condition");--> statement-breakpoint
CREATE INDEX "devices_location_idx" ON "devices" USING btree ("location_id");--> statement-breakpoint
CREATE INDEX "install_log_instance_idx" ON "installation_log" USING btree ("instance_id");--> statement-breakpoint
CREATE INDEX "install_log_device_idx" ON "installation_log" USING btree ("device_id");--> statement-breakpoint
CREATE INDEX "install_log_date_idx" ON "installation_log" USING btree ("performed_at");--> statement-breakpoint
CREATE INDEX "parts_category_idx" ON "parts" USING btree ("category");--> statement-breakpoint
CREATE INDEX "parts_location_idx" ON "parts" USING btree ("location_id");--> statement-breakpoint
CREATE INDEX "template_items_template_idx" ON "template_items" USING btree ("template_id");--> statement-breakpoint
CREATE INDEX "todos_status_idx" ON "todos" USING btree ("status");--> statement-breakpoint
CREATE INDEX "todos_priority_idx" ON "todos" USING btree ("priority");--> statement-breakpoint
CREATE INDEX "todos_device_idx" ON "todos" USING btree ("device_id");--> statement-breakpoint
CREATE INDEX "wiki_page_tags_page_idx" ON "wiki_page_tags" USING btree ("page_id");--> statement-breakpoint
CREATE INDEX "wiki_page_tags_tag_idx" ON "wiki_page_tags" USING btree ("tag_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_category_idx" ON "wiki_pages" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_slug_idx" ON "wiki_pages" USING btree ("slug");
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1782725202288,
"tag": "0000_clear_sister_grimm",
"breakpoints": true
}
]
}
+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>