diff --git a/docs/FSD.md b/docs/FSD.md index 6943f8d..82dc15b 100644 --- a/docs/FSD.md +++ b/docs/FSD.md @@ -15,12 +15,13 @@ Users with multiple rechargeable batteries often: ## 3. Core Features ### 3.1 Battery Type Management -- Define battery types (AA, AAA, 18650, CR2032, etc.) -- Associate brands with battery types -- Track total quantity per brand-type combination +- Define battery form factors (AA, AAA, 18650, CR2032, etc.) +- Define battery chemistries (NiMH, Li-ion, LiFePO4, etc.) +- Associate brands with battery types and chemistries +- Track total quantity per brand-type-chemistry combination ### 3.2 Battery Status Tracking -Each brand-type combination can have batteries in three states: +Each brand-type-chemistry combination can have batteries in three states: | Status | Description | |--------|-------------| @@ -46,10 +47,11 @@ Each brand-type combination can have batteries in three states: ## 4. User Stories ### US-01: Add Battery Type -> As a user, I want to add a new battery brand-type combination so I can track my inventory. +> As a user, I want to add a new battery brand-type-chemistry combination so I can track my inventory. **Acceptance Criteria:** -- Can select from predefined types (AA, AAA, 18650, etc.) or add custom +- Can select from predefined form factors (AA, AAA, 18650, etc.) or add custom +- Can select from predefined chemistries (NiMH, Li-ion, etc.) or add custom - Can enter brand name - Can set initial quantity and status @@ -57,9 +59,9 @@ Each brand-type combination can have batteries in three states: > As a user, I want to see all my batteries at a glance so I know what's available. **Acceptance Criteria:** -- Shows all brand-type combinations +- Shows all brand-type-chemistry combinations - Displays count per status (available/in-use/charging) -- Filterable by type or brand +- Filterable by type, brand, or chemistry ### US-03: Assign to Device > As a user, I want to assign batteries to a device so I can track where they are. @@ -88,19 +90,25 @@ Each brand-type combination can have batteries in three states: ## 5. Data Model (Conceptual) ``` -BatteryType +BatteryType (Form Factor) ├── id ├── name (AA, AAA, 18650, etc.) └── isCustom +Chemistry (Battery Chemistry) +├── id +├── name (NiMH, Li-ion, LiFePO4, etc.) +└── isCustom + Brand ├── id └── name -BatteryGroup (Brand + Type combination) +BatteryGroup (Brand + Type + Chemistry combination) ├── id ├── brandId ├── typeId +├── chemistryId ├── availableCount ├── chargingCount └── notes diff --git a/docs/TSD.md b/docs/TSD.md index aa9bf1b..05450bb 100644 --- a/docs/TSD.md +++ b/docs/TSD.md @@ -62,7 +62,7 @@ battery-tracker/ ## 3. Database Schema ```sql --- Battery types (AA, AAA, 18650, etc.) +-- Battery types / form factors (AA, AAA, 18650, etc.) CREATE TABLE battery_types ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -70,6 +70,14 @@ CREATE TABLE battery_types ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +-- Battery chemistries (NiMH, Li-ion, LiFePO4, etc.) +CREATE TABLE chemistries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + is_custom BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + -- Brands (Panasonic, Eneloop, etc.) CREATE TABLE brands ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -77,17 +85,18 @@ CREATE TABLE brands ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); --- Battery groups (Brand + Type combination with counts) +-- Battery groups (Brand + Type + Chemistry combination with counts) CREATE TABLE battery_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, brand_id INTEGER NOT NULL REFERENCES brands(id), type_id INTEGER NOT NULL REFERENCES battery_types(id), + chemistry_id INTEGER NOT NULL REFERENCES chemistries(id), available_count INTEGER DEFAULT 0, charging_count INTEGER DEFAULT 0, notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(brand_id, type_id) + UNIQUE(brand_id, type_id, chemistry_id) ); -- Devices @@ -123,6 +132,13 @@ export const batteryTypes = sqliteTable('battery_types', { createdAt: integer('created_at', { mode: 'timestamp' }).defaultNow(), }); +export const chemistries = sqliteTable('chemistries', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + isCustom: integer('is_custom', { mode: 'boolean' }).default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).defaultNow(), +}); + export const brands = sqliteTable('brands', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull().unique(), @@ -133,6 +149,7 @@ export const batteryGroups = sqliteTable('battery_groups', { id: integer('id').primaryKey({ autoIncrement: true }), brandId: integer('brand_id').notNull().references(() => brands.id), typeId: integer('type_id').notNull().references(() => batteryTypes.id), + chemistryId: integer('chemistry_id').notNull().references(() => chemistries.id), availableCount: integer('available_count').default(0), chargingCount: integer('charging_count').default(0), notes: text('notes'), @@ -162,16 +179,20 @@ export const deviceBatteries = sqliteTable('device_batteries', { ### RESTful Endpoints ``` -# Battery Types +# Battery Types (Form Factors) GET /api/types # List all battery types POST /api/types # Create custom type +# Chemistries +GET /api/chemistries # List all chemistries +POST /api/chemistries # Create custom chemistry + # Brands GET /api/brands # List all brands POST /api/brands # Create brand # Battery Groups -GET /api/batteries # List all battery groups (with brand/type info) +GET /api/batteries # List all battery groups (with brand/type/chemistry info) POST /api/batteries # Create battery group GET /api/batteries/:id # Get single battery group PATCH /api/batteries/:id # Update battery group @@ -211,7 +232,7 @@ DELETE /api/devices/:id/batteries/:gid # Remove batteries from device └── # Recent changes (optional) -├── # Filter by type, brand, status +├── # Filter by type, brand, chemistry, status ├── [] # Grid of battery groups │ ├── # Available/InUse/Charging counts │ └── diff --git a/drizzle/0001_spotty_overlord.sql b/drizzle/0001_spotty_overlord.sql new file mode 100644 index 0000000..e47efb4 --- /dev/null +++ b/drizzle/0001_spotty_overlord.sql @@ -0,0 +1,11 @@ +CREATE TABLE `chemistries` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `is_custom` integer DEFAULT false, + `created_at` text DEFAULT (datetime('now')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `chemistries_name_unique` ON `chemistries` (`name`);--> statement-breakpoint +DROP INDEX `brand_type_idx`;--> statement-breakpoint +ALTER TABLE `battery_groups` ADD `chemistry_id` integer NOT NULL REFERENCES chemistries(id);--> statement-breakpoint +CREATE UNIQUE INDEX `brand_type_chemistry_idx` ON `battery_groups` (`brand_id`,`type_id`,`chemistry_id`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..1388d9a --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,411 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f479ba6d-ed52-4368-8ba5-60410fa85d95", + "prevId": "e977fded-def0-45ae-92e8-0aca8a9f29e0", + "tables": { + "battery_groups": { + "name": "battery_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "brand_id": { + "name": "brand_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chemistry_id": { + "name": "chemistry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "charging_count": { + "name": "charging_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "brand_type_chemistry_idx": { + "name": "brand_type_chemistry_idx", + "columns": [ + "brand_id", + "type_id", + "chemistry_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "battery_groups_brand_id_brands_id_fk": { + "name": "battery_groups_brand_id_brands_id_fk", + "tableFrom": "battery_groups", + "tableTo": "brands", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "battery_groups_type_id_battery_types_id_fk": { + "name": "battery_groups_type_id_battery_types_id_fk", + "tableFrom": "battery_groups", + "tableTo": "battery_types", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "battery_groups_chemistry_id_chemistries_id_fk": { + "name": "battery_groups_chemistry_id_chemistries_id_fk", + "tableFrom": "battery_groups", + "tableTo": "chemistries", + "columnsFrom": [ + "chemistry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "battery_types": { + "name": "battery_types", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_custom": { + "name": "is_custom", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "battery_types_name_unique": { + "name": "battery_types_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "brands": { + "name": "brands", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "brands_name_unique": { + "name": "brands_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chemistries": { + "name": "chemistries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_custom": { + "name": "is_custom", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "chemistries_name_unique": { + "name": "chemistries_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_batteries": { + "name": "device_batteries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "device_id": { + "name": "device_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "battery_group_id": { + "name": "battery_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "assigned_at": { + "name": "assigned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "device_battery_idx": { + "name": "device_battery_idx", + "columns": [ + "device_id", + "battery_group_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "device_batteries_device_id_devices_id_fk": { + "name": "device_batteries_device_id_devices_id_fk", + "tableFrom": "device_batteries", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_batteries_battery_group_id_battery_groups_id_fk": { + "name": "device_batteries_battery_group_id_battery_groups_id_fk", + "tableFrom": "device_batteries", + "tableTo": "battery_groups", + "columnsFrom": [ + "battery_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "devices": { + "name": "devices", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a43509c..23a7947 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1768838495290, "tag": "0000_blue_anthem", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1768841190601, + "tag": "0001_spotty_overlord", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/batteries/[id]/route.ts b/src/app/api/batteries/[id]/route.ts index 7925341..8917ec6 100644 --- a/src/app/api/batteries/[id]/route.ts +++ b/src/app/api/batteries/[id]/route.ts @@ -19,6 +19,7 @@ export async function GET( id: schema.batteryGroups.id, brandId: schema.batteryGroups.brandId, typeId: schema.batteryGroups.typeId, + chemistryId: schema.batteryGroups.chemistryId, availableCount: schema.batteryGroups.availableCount, chargingCount: schema.batteryGroups.chargingCount, notes: schema.batteryGroups.notes, @@ -26,10 +27,12 @@ export async function GET( updatedAt: schema.batteryGroups.updatedAt, brandName: schema.brands.name, typeName: schema.batteryTypes.name, + chemistryName: schema.chemistries.name, }) .from(schema.batteryGroups) .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)) + .innerJoin(schema.chemistries, eq(schema.batteryGroups.chemistryId, schema.chemistries.id)) .where(eq(schema.batteryGroups.id, batteryId)) .limit(1); diff --git a/src/app/api/batteries/route.ts b/src/app/api/batteries/route.ts index c039ec9..72f25b5 100644 --- a/src/app/api/batteries/route.ts +++ b/src/app/api/batteries/route.ts @@ -4,12 +4,12 @@ import { eq, sql } from 'drizzle-orm'; export async function GET() { try { - // Get all battery groups with brand and type info, plus in-use count from device_batteries const groups = await db .select({ id: schema.batteryGroups.id, brandId: schema.batteryGroups.brandId, typeId: schema.batteryGroups.typeId, + chemistryId: schema.batteryGroups.chemistryId, availableCount: schema.batteryGroups.availableCount, chargingCount: schema.batteryGroups.chargingCount, notes: schema.batteryGroups.notes, @@ -17,13 +17,14 @@ export async function GET() { updatedAt: schema.batteryGroups.updatedAt, brandName: schema.brands.name, typeName: schema.batteryTypes.name, + chemistryName: schema.chemistries.name, }) .from(schema.batteryGroups) .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)) + .innerJoin(schema.chemistries, eq(schema.batteryGroups.chemistryId, schema.chemistries.id)) .orderBy(schema.brands.name, schema.batteryTypes.name); - // Get in-use counts for each battery group const inUseCounts = await db .select({ batteryGroupId: schema.deviceBatteries.batteryGroupId, @@ -49,10 +50,10 @@ export async function GET() { export async function POST(request: Request) { try { const body = await request.json(); - const { brandId, typeId, availableCount, chargingCount, notes } = body; + const { brandId, typeId, chemistryId, availableCount, chargingCount, notes } = body; - if (!brandId || !typeId) { - return NextResponse.json({ error: 'Brand and type are required' }, { status: 400 }); + if (!brandId || !typeId || !chemistryId) { + return NextResponse.json({ error: 'Brand, type, and chemistry are required' }, { status: 400 }); } const result = await db @@ -60,6 +61,7 @@ export async function POST(request: Request) { .values({ brandId, typeId, + chemistryId, availableCount: availableCount || 0, chargingCount: chargingCount || 0, notes, diff --git a/src/app/api/chemistries/route.ts b/src/app/api/chemistries/route.ts new file mode 100644 index 0000000..0e406b9 --- /dev/null +++ b/src/app/api/chemistries/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; + +export async function GET() { + try { + const chemistries = await db.select().from(schema.chemistries).orderBy(schema.chemistries.name); + return NextResponse.json(chemistries); + } catch (error) { + console.error('Failed to fetch chemistries:', error); + return NextResponse.json({ error: 'Failed to fetch chemistries' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { name } = body; + + if (!name || typeof name !== 'string') { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + } + + const result = await db + .insert(schema.chemistries) + .values({ name: name.trim(), isCustom: true }) + .returning(); + + return NextResponse.json(result[0], { status: 201 }); + } catch (error) { + console.error('Failed to create chemistry:', error); + return NextResponse.json({ error: 'Failed to create chemistry' }, { status: 500 }); + } +} diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 627531e..6bd8b38 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { db, schema } from '@/lib/db'; -import { eq, sql } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; export async function GET() { try { diff --git a/src/app/batteries/page.tsx b/src/app/batteries/page.tsx index 2655e8b..fbe176e 100644 --- a/src/app/batteries/page.tsx +++ b/src/app/batteries/page.tsx @@ -8,15 +8,18 @@ async function getBatteries() { id: schema.batteryGroups.id, brandId: schema.batteryGroups.brandId, typeId: schema.batteryGroups.typeId, + chemistryId: schema.batteryGroups.chemistryId, availableCount: schema.batteryGroups.availableCount, chargingCount: schema.batteryGroups.chargingCount, notes: schema.batteryGroups.notes, brandName: schema.brands.name, typeName: schema.batteryTypes.name, + chemistryName: schema.chemistries.name, }) .from(schema.batteryGroups) .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)) + .innerJoin(schema.chemistries, eq(schema.batteryGroups.chemistryId, schema.chemistries.id)) .orderBy(schema.brands.name, schema.batteryTypes.name); const inUseCounts = await db @@ -43,6 +46,10 @@ async function getBrands() { return db.select().from(schema.brands).orderBy(schema.brands.name); } +async function getChemistries() { + return db.select().from(schema.chemistries).orderBy(schema.chemistries.name); +} + async function getDevices() { return db.select({ id: schema.devices.id, name: schema.devices.name }) .from(schema.devices) @@ -50,10 +57,11 @@ async function getDevices() { } export default async function BatteriesPage() { - const [batteries, types, brands, devices] = await Promise.all([ + const [batteries, types, brands, chemistries, devices] = await Promise.all([ getBatteries(), getTypes(), getBrands(), + getChemistries(), getDevices(), ]); @@ -62,6 +70,7 @@ export default async function BatteriesPage() { batteries={batteries} types={types} brands={brands} + chemistries={chemistries} devices={devices} /> ); diff --git a/src/components/battery/AddBatteryModal.tsx b/src/components/battery/AddBatteryModal.tsx index 6a3376b..20550fe 100644 --- a/src/components/battery/AddBatteryModal.tsx +++ b/src/components/battery/AddBatteryModal.tsx @@ -18,14 +18,20 @@ interface Brand { name: string; } +interface Chemistry { + id: number; + name: string; +} + interface AddBatteryModalProps { isOpen: boolean; onClose: () => void; types: BatteryType[]; brands: Brand[]; + chemistries: Chemistry[]; } -export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryModalProps) { +export function AddBatteryModal({ isOpen, onClose, types, brands, chemistries }: AddBatteryModalProps) { const router = useRouter(); const { showToast } = useToast(); const [loading, setLoading] = useState(false); @@ -34,6 +40,7 @@ export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryMo const [formData, setFormData] = useState({ brandId: '', typeId: '', + chemistryId: '', availableCount: '0', chargingCount: '0', notes: '', @@ -62,8 +69,8 @@ export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryMo brandId = newBrand.id.toString(); } - if (!brandId || !formData.typeId) { - showToast('error', 'Please select a brand and type'); + if (!brandId || !formData.typeId || !formData.chemistryId) { + showToast('error', 'Please select brand, type, and chemistry'); setLoading(false); return; } @@ -74,6 +81,7 @@ export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryMo body: JSON.stringify({ brandId: parseInt(brandId), typeId: parseInt(formData.typeId), + chemistryId: parseInt(formData.chemistryId), availableCount: parseInt(formData.availableCount) || 0, chargingCount: parseInt(formData.chargingCount) || 0, notes: formData.notes || null, @@ -92,6 +100,7 @@ export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryMo setFormData({ brandId: '', typeId: '', + chemistryId: '', availableCount: '0', chargingCount: '0', notes: '', @@ -107,6 +116,7 @@ export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryMo const brandOptions = brands.map((b) => ({ value: b.id, label: b.name })); const typeOptions = types.map((t) => ({ value: t.id, label: t.name })); + const chemistryOptions = chemistries.map((c) => ({ value: c.id, label: c.name })); return ( setFormData({ ...formData, typeId: e.target.value })} placeholder="Select battery type" /> +

- {battery.brandName} {battery.typeName} + {battery.brandName} {battery.typeName} ({battery.chemistryName})

Total: {total} batteries

@@ -346,7 +347,7 @@ export function BatteryCard({ battery, devices }: BatteryCardProps) { } >

- Are you sure you want to delete {battery.brandName} {battery.typeName}? + Are you sure you want to delete {battery.brandName} {battery.typeName} ({battery.chemistryName})? This action cannot be undone.

{battery.inUseCount > 0 && ( diff --git a/src/components/battery/BatteryListClient.tsx b/src/components/battery/BatteryListClient.tsx index f1c6082..b823ef6 100644 --- a/src/components/battery/BatteryListClient.tsx +++ b/src/components/battery/BatteryListClient.tsx @@ -16,6 +16,11 @@ interface Brand { name: string; } +interface Chemistry { + id: number; + name: string; +} + interface Device { id: number; name: string; @@ -25,6 +30,7 @@ interface BatteryGroup { id: number; brandName: string; typeName: string; + chemistryName: string; availableCount: number; chargingCount: number; inUseCount: number; @@ -35,10 +41,11 @@ interface BatteryListClientProps { batteries: BatteryGroup[]; types: BatteryType[]; brands: Brand[]; + chemistries: Chemistry[]; devices: Device[]; } -export function BatteryListClient({ batteries, types, brands, devices }: BatteryListClientProps) { +export function BatteryListClient({ batteries, types, brands, chemistries, devices }: BatteryListClientProps) { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [filter, setFilter] = useState('all'); @@ -107,6 +114,7 @@ export function BatteryListClient({ batteries, types, brands, devices }: Battery onClose={() => setIsAddModalOpen(false)} types={types} brands={brands} + chemistries={chemistries} /> ); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 425c35e..2056c84 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -9,6 +9,14 @@ export const batteryTypes = sqliteTable('battery_types', { createdAt: text('created_at').default(sql`(datetime('now'))`), }); +// Battery chemistries (NiMH, Li-ion, LiFePO4, etc.) +export const chemistries = sqliteTable('chemistries', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + isCustom: integer('is_custom', { mode: 'boolean' }).default(false), + createdAt: text('created_at').default(sql`(datetime('now'))`), +}); + // Brands (Panasonic, Eneloop, etc.) export const brands = sqliteTable('brands', { id: integer('id').primaryKey({ autoIncrement: true }), @@ -16,18 +24,19 @@ export const brands = sqliteTable('brands', { createdAt: text('created_at').default(sql`(datetime('now'))`), }); -// Battery groups (Brand + Type combination with counts) +// Battery groups (Brand + Type + Chemistry combination with counts) export const batteryGroups = sqliteTable('battery_groups', { id: integer('id').primaryKey({ autoIncrement: true }), brandId: integer('brand_id').notNull().references(() => brands.id), typeId: integer('type_id').notNull().references(() => batteryTypes.id), + chemistryId: integer('chemistry_id').notNull().references(() => chemistries.id), availableCount: integer('available_count').default(0).notNull(), chargingCount: integer('charging_count').default(0).notNull(), notes: text('notes'), createdAt: text('created_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`), }, (table) => [ - uniqueIndex('brand_type_idx').on(table.brandId, table.typeId), + uniqueIndex('brand_type_chemistry_idx').on(table.brandId, table.typeId, table.chemistryId), ]); // Devices @@ -55,6 +64,10 @@ export const batteryTypesRelations = relations(batteryTypes, ({ many }) => ({ batteryGroups: many(batteryGroups), })); +export const chemistriesRelations = relations(chemistries, ({ many }) => ({ + batteryGroups: many(batteryGroups), +})); + export const brandsRelations = relations(brands, ({ many }) => ({ batteryGroups: many(batteryGroups), })); @@ -68,6 +81,10 @@ export const batteryGroupsRelations = relations(batteryGroups, ({ one, many }) = fields: [batteryGroups.typeId], references: [batteryTypes.id], }), + chemistry: one(chemistries, { + fields: [batteryGroups.chemistryId], + references: [chemistries.id], + }), deviceBatteries: many(deviceBatteries), })); @@ -89,6 +106,8 @@ export const deviceBatteriesRelations = relations(deviceBatteries, ({ one }) => // Type exports export type BatteryType = typeof batteryTypes.$inferSelect; export type NewBatteryType = typeof batteryTypes.$inferInsert; +export type Chemistry = typeof chemistries.$inferSelect; +export type NewChemistry = typeof chemistries.$inferInsert; export type Brand = typeof brands.$inferSelect; export type NewBrand = typeof brands.$inferInsert; export type BatteryGroup = typeof batteryGroups.$inferSelect; diff --git a/src/lib/db/seed.ts b/src/lib/db/seed.ts index 9c6e70e..8a1fe98 100644 --- a/src/lib/db/seed.ts +++ b/src/lib/db/seed.ts @@ -37,5 +37,19 @@ for (const typeName of defaultTypes) { } } +// Seed default chemistries +const defaultChemistries = ['NiMH', 'Li-ion', 'LiFePO4', 'NiCd', 'Li-Po', 'Alkaline']; + +console.log('Seeding default chemistries...'); +for (const chemName of defaultChemistries) { + try { + db.insert(schema.chemistries).values({ name: chemName, isCustom: false }).run(); + console.log(` Added: ${chemName}`); + } catch { + // Already exists, skip + console.log(` Skipped (exists): ${chemName}`); + } +} + console.log('Seed complete!'); sqlite.close();