feat: Add battery chemistry as third identification dimension
Battery groups are now identified by a 3-element combination: brand, type, and chemistry (NiMH, Li-ion, LiFePO4, etc.) instead of just brand and type. Changes: - Add chemistries table with default values - Update battery_groups schema with chemistry_id foreign key - Create /api/chemistries endpoint for CRUD operations - Update UI components to display and select chemistry - Update documentation (FSD.md, TSD.md) 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
parent
e7ca93f510
commit
9c4a9a141a
28
docs/FSD.md
28
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
|
||||
|
|
|
|||
33
docs/TSD.md
33
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
|
|||
└── <RecentActivity /> # Recent changes (optional)
|
||||
|
||||
<BatteryList>
|
||||
├── <FilterBar /> # Filter by type, brand, status
|
||||
├── <FilterBar /> # Filter by type, brand, chemistry, status
|
||||
├── <BatteryCard />[] # Grid of battery groups
|
||||
│ ├── <StatusBadges /> # Available/InUse/Charging counts
|
||||
│ └── <QuickActionButtons />
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1768838495290,
|
||||
"tag": "0000_blue_anthem",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1768841190601,
|
||||
"tag": "0001_spotty_overlord",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Modal
|
||||
|
|
@ -164,13 +174,21 @@ export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryMo
|
|||
)}
|
||||
|
||||
<Select
|
||||
label="Type"
|
||||
label="Type (Form Factor)"
|
||||
options={typeOptions}
|
||||
value={formData.typeId}
|
||||
onChange={(e) => setFormData({ ...formData, typeId: e.target.value })}
|
||||
placeholder="Select battery type"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Chemistry"
|
||||
options={chemistryOptions}
|
||||
value={formData.chemistryId}
|
||||
onChange={(e) => setFormData({ ...formData, chemistryId: e.target.value })}
|
||||
placeholder="Select chemistry"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Available Count"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ interface BatteryGroup {
|
|||
id: number;
|
||||
brandName: string;
|
||||
typeName: string;
|
||||
chemistryName: string;
|
||||
availableCount: number;
|
||||
chargingCount: number;
|
||||
inUseCount: number;
|
||||
|
|
@ -143,7 +144,7 @@ export function BatteryCard({ battery, devices }: BatteryCardProps) {
|
|||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
{battery.brandName} {battery.typeName}
|
||||
{battery.brandName} {battery.typeName} ({battery.chemistryName})
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">Total: {total} batteries</p>
|
||||
</div>
|
||||
|
|
@ -346,7 +347,7 @@ export function BatteryCard({ battery, devices }: BatteryCardProps) {
|
|||
}
|
||||
>
|
||||
<p className="text-slate-600">
|
||||
Are you sure you want to delete <strong>{battery.brandName} {battery.typeName}</strong>?
|
||||
Are you sure you want to delete <strong>{battery.brandName} {battery.typeName} ({battery.chemistryName})</strong>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
{battery.inUseCount > 0 && (
|
||||
|
|
|
|||
|
|
@ -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<string>('all');
|
||||
|
||||
|
|
@ -107,6 +114,7 @@ export function BatteryListClient({ batteries, types, brands, devices }: Battery
|
|||
onClose={() => setIsAddModalOpen(false)}
|
||||
types={types}
|
||||
brands={brands}
|
||||
chemistries={chemistries}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue