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:
root 2026-01-19 16:59:59 +00:00
parent e7ca93f510
commit 9c4a9a141a
15 changed files with 597 additions and 32 deletions

View File

@ -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

View File

@ -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 />

View File

@ -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`);

View File

@ -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": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1768838495290,
"tag": "0000_blue_anthem",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768841190601,
"tag": "0001_spotty_overlord",
"breakpoints": true
}
]
}

View File

@ -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);

View File

@ -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,

View File

@ -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 });
}
}

View File

@ -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 {

View File

@ -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}
/>
);

View File

@ -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"

View File

@ -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 && (

View File

@ -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>
);

View File

@ -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;

View File

@ -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();