feat: Initial implementation of Battery Tracker

- Dashboard with stats overview (available, in-use, charging counts)
- Battery management (add, edit, delete battery groups by brand/type)
- Device management (add, edit, delete devices)
- Assign/remove batteries to/from devices
- Move batteries between states (available/charging)
- SQLite database with Drizzle ORM
- Responsive UI with Tailwind CSS

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
root 2026-01-19 16:26:36 +00:00
parent 289f83ad5c
commit e7ca93f510
41 changed files with 5042 additions and 76 deletions

3
.gitignore vendored
View File

@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# database
/data/*.db

10
drizzle.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: './data/battery_tracker.db',
},
});

View File

@ -0,0 +1,47 @@
CREATE TABLE `battery_groups` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`brand_id` integer NOT NULL,
`type_id` integer NOT NULL,
`available_count` integer DEFAULT 0 NOT NULL,
`charging_count` integer DEFAULT 0 NOT NULL,
`notes` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`brand_id`) REFERENCES `brands`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`type_id`) REFERENCES `battery_types`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `brand_type_idx` ON `battery_groups` (`brand_id`,`type_id`);--> statement-breakpoint
CREATE TABLE `battery_types` (
`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 `battery_types_name_unique` ON `battery_types` (`name`);--> statement-breakpoint
CREATE TABLE `brands` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`created_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `brands_name_unique` ON `brands` (`name`);--> statement-breakpoint
CREATE TABLE `device_batteries` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`device_id` integer NOT NULL,
`battery_group_id` integer NOT NULL,
`quantity` integer DEFAULT 1 NOT NULL,
`assigned_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`battery_group_id`) REFERENCES `battery_groups`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `device_battery_idx` ON `device_batteries` (`device_id`,`battery_group_id`);--> statement-breakpoint
CREATE TABLE `devices` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);

View File

@ -0,0 +1,342 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e977fded-def0-45ae-92e8-0aca8a9f29e0",
"prevId": "00000000-0000-0000-0000-000000000000",
"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
},
"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_idx": {
"name": "brand_type_idx",
"columns": [
"brand_id",
"type_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"
}
},
"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": {}
},
"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

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1768838495290,
"tag": "0000_blue_anthem",
"breakpoints": true
}
]
}

1572
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,15 +9,20 @@
"lint": "eslint"
},
"dependencies": {
"better-sqlite3": "^12.6.2",
"drizzle-orm": "^0.45.1",
"lucide-react": "^0.562.0",
"next": "16.1.3",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.8",
"eslint": "^9",
"eslint-config-next": "16.1.3",
"tailwindcss": "^4",

View File

@ -0,0 +1,98 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
import { eq, and, sql } from 'drizzle-orm';
// Assign batteries from available to a device
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const batteryId = parseInt(id);
if (isNaN(batteryId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const body = await request.json();
const { deviceId, quantity } = body;
if (!deviceId || !quantity || quantity < 1) {
return NextResponse.json({ error: 'Device ID and quantity are required' }, { status: 400 });
}
// Check available count
const battery = await db
.select()
.from(schema.batteryGroups)
.where(eq(schema.batteryGroups.id, batteryId))
.limit(1);
if (battery.length === 0) {
return NextResponse.json({ error: 'Battery group not found' }, { status: 404 });
}
if (battery[0].availableCount < quantity) {
return NextResponse.json({ error: 'Not enough available batteries' }, { status: 400 });
}
// Check device exists
const device = await db
.select()
.from(schema.devices)
.where(eq(schema.devices.id, deviceId))
.limit(1);
if (device.length === 0) {
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
}
// Check if there's an existing assignment
const existingAssignment = await db
.select()
.from(schema.deviceBatteries)
.where(
and(
eq(schema.deviceBatteries.deviceId, deviceId),
eq(schema.deviceBatteries.batteryGroupId, batteryId)
)
)
.limit(1);
// Start transaction-like operations
// Decrement available count
await db
.update(schema.batteryGroups)
.set({
availableCount: sql`${schema.batteryGroups.availableCount} - ${quantity}`,
updatedAt: sql`datetime('now')`,
})
.where(eq(schema.batteryGroups.id, batteryId));
if (existingAssignment.length > 0) {
// Update existing assignment
await db
.update(schema.deviceBatteries)
.set({
quantity: sql`${schema.deviceBatteries.quantity} + ${quantity}`,
assignedAt: sql`datetime('now')`,
})
.where(eq(schema.deviceBatteries.id, existingAssignment[0].id));
} else {
// Create new assignment
await db
.insert(schema.deviceBatteries)
.values({
deviceId,
batteryGroupId: batteryId,
quantity,
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to assign batteries:', error);
return NextResponse.json({ error: 'Failed to assign batteries' }, { status: 500 });
}
}

View File

@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
import { eq, sql } from 'drizzle-orm';
// Move batteries from charging to available (charging complete)
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const batteryId = parseInt(id);
if (isNaN(batteryId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const body = await request.json();
const { count } = body;
if (!count || count < 1) {
return NextResponse.json({ error: 'Count must be at least 1' }, { status: 400 });
}
// Check charging count
const battery = await db
.select()
.from(schema.batteryGroups)
.where(eq(schema.batteryGroups.id, batteryId))
.limit(1);
if (battery.length === 0) {
return NextResponse.json({ error: 'Battery group not found' }, { status: 404 });
}
if (battery[0].chargingCount < count) {
return NextResponse.json({ error: 'Not enough batteries charging' }, { status: 400 });
}
const result = await db
.update(schema.batteryGroups)
.set({
chargingCount: sql`${schema.batteryGroups.chargingCount} - ${count}`,
availableCount: sql`${schema.batteryGroups.availableCount} + ${count}`,
updatedAt: sql`datetime('now')`,
})
.where(eq(schema.batteryGroups.id, batteryId))
.returning();
return NextResponse.json(result[0]);
} catch (error) {
console.error('Failed to mark available:', error);
return NextResponse.json({ error: 'Failed to mark available' }, { status: 500 });
}
}

View File

@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
import { eq, sql } from 'drizzle-orm';
// Move batteries from available to charging
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const batteryId = parseInt(id);
if (isNaN(batteryId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const body = await request.json();
const { count } = body;
if (!count || count < 1) {
return NextResponse.json({ error: 'Count must be at least 1' }, { status: 400 });
}
// Check available count
const battery = await db
.select()
.from(schema.batteryGroups)
.where(eq(schema.batteryGroups.id, batteryId))
.limit(1);
if (battery.length === 0) {
return NextResponse.json({ error: 'Battery group not found' }, { status: 404 });
}
if (battery[0].availableCount < count) {
return NextResponse.json({ error: 'Not enough available batteries' }, { status: 400 });
}
const result = await db
.update(schema.batteryGroups)
.set({
availableCount: sql`${schema.batteryGroups.availableCount} - ${count}`,
chargingCount: sql`${schema.batteryGroups.chargingCount} + ${count}`,
updatedAt: sql`datetime('now')`,
})
.where(eq(schema.batteryGroups.id, batteryId))
.returning();
return NextResponse.json(result[0]);
} catch (error) {
console.error('Failed to start charging:', error);
return NextResponse.json({ error: 'Failed to start charging' }, { status: 500 });
}
}

View File

@ -0,0 +1,151 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
import { eq, sql } from 'drizzle-orm';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const batteryId = parseInt(id);
if (isNaN(batteryId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const result = await db
.select({
id: schema.batteryGroups.id,
brandId: schema.batteryGroups.brandId,
typeId: schema.batteryGroups.typeId,
availableCount: schema.batteryGroups.availableCount,
chargingCount: schema.batteryGroups.chargingCount,
notes: schema.batteryGroups.notes,
createdAt: schema.batteryGroups.createdAt,
updatedAt: schema.batteryGroups.updatedAt,
brandName: schema.brands.name,
typeName: schema.batteryTypes.name,
})
.from(schema.batteryGroups)
.innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id))
.innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id))
.where(eq(schema.batteryGroups.id, batteryId))
.limit(1);
if (result.length === 0) {
return NextResponse.json({ error: 'Battery group not found' }, { status: 404 });
}
// Get in-use count
const inUseResult = await db
.select({
inUseCount: sql<number>`SUM(${schema.deviceBatteries.quantity})`.as('in_use_count'),
})
.from(schema.deviceBatteries)
.where(eq(schema.deviceBatteries.batteryGroupId, batteryId));
// Get devices using this battery
const deviceAssignments = await db
.select({
deviceId: schema.devices.id,
deviceName: schema.devices.name,
quantity: schema.deviceBatteries.quantity,
assignedAt: schema.deviceBatteries.assignedAt,
})
.from(schema.deviceBatteries)
.innerJoin(schema.devices, eq(schema.deviceBatteries.deviceId, schema.devices.id))
.where(eq(schema.deviceBatteries.batteryGroupId, batteryId));
return NextResponse.json({
...result[0],
inUseCount: inUseResult[0]?.inUseCount || 0,
deviceAssignments,
});
} catch (error) {
console.error('Failed to fetch battery:', error);
return NextResponse.json({ error: 'Failed to fetch battery' }, { status: 500 });
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const batteryId = parseInt(id);
if (isNaN(batteryId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const body = await request.json();
const { availableCount, chargingCount, notes } = body;
const updateData: Record<string, unknown> = {
updatedAt: sql`datetime('now')`,
};
if (availableCount !== undefined) updateData.availableCount = availableCount;
if (chargingCount !== undefined) updateData.chargingCount = chargingCount;
if (notes !== undefined) updateData.notes = notes;
const result = await db
.update(schema.batteryGroups)
.set(updateData)
.where(eq(schema.batteryGroups.id, batteryId))
.returning();
if (result.length === 0) {
return NextResponse.json({ error: 'Battery group not found' }, { status: 404 });
}
return NextResponse.json(result[0]);
} catch (error) {
console.error('Failed to update battery:', error);
return NextResponse.json({ error: 'Failed to update battery' }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const batteryId = parseInt(id);
if (isNaN(batteryId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
// Check if any devices are using this battery group
const assignments = await db
.select()
.from(schema.deviceBatteries)
.where(eq(schema.deviceBatteries.batteryGroupId, batteryId))
.limit(1);
if (assignments.length > 0) {
return NextResponse.json(
{ error: 'Cannot delete battery group while batteries are assigned to devices' },
{ status: 400 }
);
}
const result = await db
.delete(schema.batteryGroups)
.where(eq(schema.batteryGroups.id, batteryId))
.returning();
if (result.length === 0) {
return NextResponse.json({ error: 'Battery group not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete battery:', error);
return NextResponse.json({ error: 'Failed to delete battery' }, { status: 500 });
}
}

View File

@ -0,0 +1,74 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
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,
availableCount: schema.batteryGroups.availableCount,
chargingCount: schema.batteryGroups.chargingCount,
notes: schema.batteryGroups.notes,
createdAt: schema.batteryGroups.createdAt,
updatedAt: schema.batteryGroups.updatedAt,
brandName: schema.brands.name,
typeName: schema.batteryTypes.name,
})
.from(schema.batteryGroups)
.innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id))
.innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.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,
inUseCount: sql<number>`SUM(${schema.deviceBatteries.quantity})`.as('in_use_count'),
})
.from(schema.deviceBatteries)
.groupBy(schema.deviceBatteries.batteryGroupId);
const inUseMap = new Map(inUseCounts.map((r) => [r.batteryGroupId, r.inUseCount || 0]));
const result = groups.map((g) => ({
...g,
inUseCount: inUseMap.get(g.id) || 0,
}));
return NextResponse.json(result);
} catch (error) {
console.error('Failed to fetch batteries:', error);
return NextResponse.json({ error: 'Failed to fetch batteries' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { brandId, typeId, availableCount, chargingCount, notes } = body;
if (!brandId || !typeId) {
return NextResponse.json({ error: 'Brand and type are required' }, { status: 400 });
}
const result = await db
.insert(schema.batteryGroups)
.values({
brandId,
typeId,
availableCount: availableCount || 0,
chargingCount: chargingCount || 0,
notes,
})
.returning();
return NextResponse.json(result[0], { status: 201 });
} catch (error) {
console.error('Failed to create battery group:', error);
return NextResponse.json({ error: 'Failed to create battery group' }, { status: 500 });
}
}

View File

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
export async function GET() {
try {
const brands = await db.select().from(schema.brands).orderBy(schema.brands.name);
return NextResponse.json(brands);
} catch (error) {
console.error('Failed to fetch brands:', error);
return NextResponse.json({ error: 'Failed to fetch brands' }, { 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.brands)
.values({ name: name.trim() })
.returning();
return NextResponse.json(result[0], { status: 201 });
} catch (error) {
console.error('Failed to create brand:', error);
return NextResponse.json({ error: 'Failed to create brand' }, { status: 500 });
}
}

View File

@ -0,0 +1,85 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
import { eq, and, sql } from 'drizzle-orm';
// Remove batteries from device (return to available or charging)
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const deviceId = parseInt(id);
if (isNaN(deviceId)) {
return NextResponse.json({ error: 'Invalid device ID' }, { status: 400 });
}
const { searchParams } = new URL(request.url);
const batteryGroupId = parseInt(searchParams.get('batteryGroupId') || '');
const quantity = parseInt(searchParams.get('quantity') || '0');
const destination = searchParams.get('destination') || 'available'; // 'available' or 'charging'
if (isNaN(batteryGroupId)) {
return NextResponse.json({ error: 'Invalid battery group ID' }, { status: 400 });
}
// Get current assignment
const assignment = await db
.select()
.from(schema.deviceBatteries)
.where(
and(
eq(schema.deviceBatteries.deviceId, deviceId),
eq(schema.deviceBatteries.batteryGroupId, batteryGroupId)
)
)
.limit(1);
if (assignment.length === 0) {
return NextResponse.json({ error: 'Assignment not found' }, { status: 404 });
}
const removeCount = quantity > 0 ? Math.min(quantity, assignment[0].quantity) : assignment[0].quantity;
// Update or delete assignment
if (removeCount >= assignment[0].quantity) {
// Remove entire assignment
await db
.delete(schema.deviceBatteries)
.where(eq(schema.deviceBatteries.id, assignment[0].id));
} else {
// Reduce quantity
await db
.update(schema.deviceBatteries)
.set({
quantity: sql`${schema.deviceBatteries.quantity} - ${removeCount}`,
})
.where(eq(schema.deviceBatteries.id, assignment[0].id));
}
// Return batteries to the specified destination
if (destination === 'charging') {
await db
.update(schema.batteryGroups)
.set({
chargingCount: sql`${schema.batteryGroups.chargingCount} + ${removeCount}`,
updatedAt: sql`datetime('now')`,
})
.where(eq(schema.batteryGroups.id, batteryGroupId));
} else {
await db
.update(schema.batteryGroups)
.set({
availableCount: sql`${schema.batteryGroups.availableCount} + ${removeCount}`,
updatedAt: sql`datetime('now')`,
})
.where(eq(schema.batteryGroups.id, batteryGroupId));
}
return NextResponse.json({ success: true, removed: removeCount });
} catch (error) {
console.error('Failed to remove batteries from device:', error);
return NextResponse.json({ error: 'Failed to remove batteries' }, { status: 500 });
}
}

View File

@ -0,0 +1,136 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
import { eq, sql } from 'drizzle-orm';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const deviceId = parseInt(id);
if (isNaN(deviceId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const device = await db
.select()
.from(schema.devices)
.where(eq(schema.devices.id, deviceId))
.limit(1);
if (device.length === 0) {
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
}
// Get battery assignments
const assignments = await db
.select({
id: schema.deviceBatteries.id,
batteryGroupId: schema.deviceBatteries.batteryGroupId,
quantity: schema.deviceBatteries.quantity,
assignedAt: schema.deviceBatteries.assignedAt,
brandName: schema.brands.name,
typeName: schema.batteryTypes.name,
})
.from(schema.deviceBatteries)
.innerJoin(schema.batteryGroups, eq(schema.deviceBatteries.batteryGroupId, schema.batteryGroups.id))
.innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id))
.innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id))
.where(eq(schema.deviceBatteries.deviceId, deviceId));
return NextResponse.json({
...device[0],
batteries: assignments,
});
} catch (error) {
console.error('Failed to fetch device:', error);
return NextResponse.json({ error: 'Failed to fetch device' }, { status: 500 });
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const deviceId = parseInt(id);
if (isNaN(deviceId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const body = await request.json();
const { name, description } = body;
const updateData: Record<string, unknown> = {
updatedAt: sql`datetime('now')`,
};
if (name !== undefined) updateData.name = name.trim();
if (description !== undefined) updateData.description = description;
const result = await db
.update(schema.devices)
.set(updateData)
.where(eq(schema.devices.id, deviceId))
.returning();
if (result.length === 0) {
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
}
return NextResponse.json(result[0]);
} catch (error) {
console.error('Failed to update device:', error);
return NextResponse.json({ error: 'Failed to update device' }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const deviceId = parseInt(id);
if (isNaN(deviceId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
// Get all battery assignments before deleting
const assignments = await db
.select()
.from(schema.deviceBatteries)
.where(eq(schema.deviceBatteries.deviceId, deviceId));
// Return batteries to available
for (const assignment of assignments) {
await db
.update(schema.batteryGroups)
.set({
availableCount: sql`${schema.batteryGroups.availableCount} + ${assignment.quantity}`,
updatedAt: sql`datetime('now')`,
})
.where(eq(schema.batteryGroups.id, assignment.batteryGroupId));
}
// Delete device (cascade will remove assignments)
const result = await db
.delete(schema.devices)
.where(eq(schema.devices.id, deviceId))
.returning();
if (result.length === 0) {
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete device:', error);
return NextResponse.json({ error: 'Failed to delete device' }, { status: 500 });
}
}

View File

@ -0,0 +1,71 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
import { eq, sql } from 'drizzle-orm';
export async function GET() {
try {
// Get all devices
const devices = await db
.select()
.from(schema.devices)
.orderBy(schema.devices.name);
// Get battery assignments for all devices
const assignments = await db
.select({
deviceId: schema.deviceBatteries.deviceId,
batteryGroupId: schema.deviceBatteries.batteryGroupId,
quantity: schema.deviceBatteries.quantity,
assignedAt: schema.deviceBatteries.assignedAt,
brandName: schema.brands.name,
typeName: schema.batteryTypes.name,
})
.from(schema.deviceBatteries)
.innerJoin(schema.batteryGroups, eq(schema.deviceBatteries.batteryGroupId, schema.batteryGroups.id))
.innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id))
.innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id));
// Group assignments by device
const assignmentsByDevice = new Map<number, typeof assignments>();
for (const assignment of assignments) {
const existing = assignmentsByDevice.get(assignment.deviceId) || [];
existing.push(assignment);
assignmentsByDevice.set(assignment.deviceId, existing);
}
const result = devices.map((device) => ({
...device,
batteries: assignmentsByDevice.get(device.id) || [],
totalBatteries: (assignmentsByDevice.get(device.id) || []).reduce(
(sum, a) => sum + a.quantity,
0
),
}));
return NextResponse.json(result);
} catch (error) {
console.error('Failed to fetch devices:', error);
return NextResponse.json({ error: 'Failed to fetch devices' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { name, description } = body;
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const result = await db
.insert(schema.devices)
.values({ name: name.trim(), description })
.returning();
return NextResponse.json(result[0], { status: 201 });
} catch (error) {
console.error('Failed to create device:', error);
return NextResponse.json({ error: 'Failed to create device' }, { status: 500 });
}
}

View File

@ -0,0 +1,68 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
import { sql } from 'drizzle-orm';
export async function GET() {
try {
// Get total available and charging counts
const batteryCounts = await db
.select({
available: sql<number>`COALESCE(SUM(${schema.batteryGroups.availableCount}), 0)`,
charging: sql<number>`COALESCE(SUM(${schema.batteryGroups.chargingCount}), 0)`,
})
.from(schema.batteryGroups);
// Get total in-use count
const inUseCounts = await db
.select({
inUse: sql<number>`COALESCE(SUM(${schema.deviceBatteries.quantity}), 0)`,
})
.from(schema.deviceBatteries);
// Get counts by type
const byType = await db
.select({
typeName: schema.batteryTypes.name,
available: sql<number>`COALESCE(SUM(${schema.batteryGroups.availableCount}), 0)`,
charging: sql<number>`COALESCE(SUM(${schema.batteryGroups.chargingCount}), 0)`,
})
.from(schema.batteryGroups)
.innerJoin(schema.batteryTypes, sql`${schema.batteryGroups.typeId} = ${schema.batteryTypes.id}`)
.groupBy(schema.batteryTypes.name)
.orderBy(schema.batteryTypes.name);
// Get device count
const deviceCount = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(schema.devices);
// Get battery group count
const groupCount = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(schema.batteryGroups);
return NextResponse.json({
totals: {
available: batteryCounts[0]?.available || 0,
charging: batteryCounts[0]?.charging || 0,
inUse: inUseCounts[0]?.inUse || 0,
total:
(batteryCounts[0]?.available || 0) +
(batteryCounts[0]?.charging || 0) +
(inUseCounts[0]?.inUse || 0),
},
byType,
counts: {
devices: deviceCount[0]?.count || 0,
batteryGroups: groupCount[0]?.count || 0,
},
});
} catch (error) {
console.error('Failed to fetch stats:', error);
return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 });
}
}

View File

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { db, schema } from '@/lib/db';
export async function GET() {
try {
const types = await db.select().from(schema.batteryTypes).orderBy(schema.batteryTypes.name);
return NextResponse.json(types);
} catch (error) {
console.error('Failed to fetch battery types:', error);
return NextResponse.json({ error: 'Failed to fetch battery types' }, { 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.batteryTypes)
.values({ name: name.trim(), isCustom: true })
.returning();
return NextResponse.json(result[0], { status: 201 });
} catch (error) {
console.error('Failed to create battery type:', error);
return NextResponse.json({ error: 'Failed to create battery type' }, { status: 500 });
}
}

View File

@ -0,0 +1,68 @@
import { db, schema } from '@/lib/db';
import { eq, sql } from 'drizzle-orm';
import { BatteryListClient } from '@/components/battery/BatteryListClient';
async function getBatteries() {
const groups = await db
.select({
id: schema.batteryGroups.id,
brandId: schema.batteryGroups.brandId,
typeId: schema.batteryGroups.typeId,
availableCount: schema.batteryGroups.availableCount,
chargingCount: schema.batteryGroups.chargingCount,
notes: schema.batteryGroups.notes,
brandName: schema.brands.name,
typeName: schema.batteryTypes.name,
})
.from(schema.batteryGroups)
.innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id))
.innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id))
.orderBy(schema.brands.name, schema.batteryTypes.name);
const inUseCounts = await db
.select({
batteryGroupId: schema.deviceBatteries.batteryGroupId,
inUseCount: sql<number>`SUM(${schema.deviceBatteries.quantity})`.as('in_use_count'),
})
.from(schema.deviceBatteries)
.groupBy(schema.deviceBatteries.batteryGroupId);
const inUseMap = new Map(inUseCounts.map((r) => [r.batteryGroupId, r.inUseCount || 0]));
return groups.map((g) => ({
...g,
inUseCount: inUseMap.get(g.id) || 0,
}));
}
async function getTypes() {
return db.select().from(schema.batteryTypes).orderBy(schema.batteryTypes.name);
}
async function getBrands() {
return db.select().from(schema.brands).orderBy(schema.brands.name);
}
async function getDevices() {
return db.select({ id: schema.devices.id, name: schema.devices.name })
.from(schema.devices)
.orderBy(schema.devices.name);
}
export default async function BatteriesPage() {
const [batteries, types, brands, devices] = await Promise.all([
getBatteries(),
getTypes(),
getBrands(),
getDevices(),
]);
return (
<BatteryListClient
batteries={batteries}
types={types}
brands={brands}
devices={devices}
/>
);
}

45
src/app/devices/page.tsx Normal file
View File

@ -0,0 +1,45 @@
import { db, schema } from '@/lib/db';
import { eq } from 'drizzle-orm';
import { DeviceListClient } from '@/components/device/DeviceListClient';
async function getDevices() {
const devices = await db
.select()
.from(schema.devices)
.orderBy(schema.devices.name);
const assignments = await db
.select({
deviceId: schema.deviceBatteries.deviceId,
batteryGroupId: schema.deviceBatteries.batteryGroupId,
quantity: schema.deviceBatteries.quantity,
brandName: schema.brands.name,
typeName: schema.batteryTypes.name,
})
.from(schema.deviceBatteries)
.innerJoin(schema.batteryGroups, eq(schema.deviceBatteries.batteryGroupId, schema.batteryGroups.id))
.innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id))
.innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id));
const assignmentsByDevice = new Map<number, typeof assignments>();
for (const assignment of assignments) {
const existing = assignmentsByDevice.get(assignment.deviceId) || [];
existing.push(assignment);
assignmentsByDevice.set(assignment.deviceId, existing);
}
return devices.map((device) => {
const deviceBatteries = assignmentsByDevice.get(device.id) || [];
return {
...device,
batteries: deviceBatteries,
totalBatteries: deviceBatteries.reduce((sum, a) => sum + a.quantity, 0),
};
});
}
export default async function DevicesPage() {
const devices = await getDevices();
return <DeviceListClient devices={devices} />;
}

View File

@ -1,8 +1,8 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f8fafc;
--foreground: #0f172a;
}
@theme inline {
@ -12,15 +12,32 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}
/* Animation for toasts */
@keyframes slide-in-from-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-in {
animation-fill-mode: both;
}
.slide-in-from-right-5 {
animation-name: slide-in-from-right;
}
.duration-300 {
animation-duration: 300ms;
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Navigation } from "@/components/Navigation";
import { ToastProvider } from "@/components/ui/Toast";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Battery Tracker",
description: "Track your rechargeable batteries across devices",
};
export default function RootLayout({
@ -25,9 +27,14 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-slate-50`}
>
{children}
<ToastProvider>
<Navigation />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</ToastProvider>
</body>
</html>
);

View File

@ -1,65 +1,284 @@
import Image from "next/image";
import { db, schema } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Battery, BatteryCharging, Package, Monitor } from 'lucide-react';
import Link from 'next/link';
async function getStats() {
// Get total available and charging counts
const batteryCounts = await db
.select({
available: sql<number>`COALESCE(SUM(${schema.batteryGroups.availableCount}), 0)`,
charging: sql<number>`COALESCE(SUM(${schema.batteryGroups.chargingCount}), 0)`,
})
.from(schema.batteryGroups);
// Get total in-use count
const inUseCounts = await db
.select({
inUse: sql<number>`COALESCE(SUM(${schema.deviceBatteries.quantity}), 0)`,
})
.from(schema.deviceBatteries);
// Get device count
const deviceCount = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(schema.devices);
// Get battery group count
const groupCount = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(schema.batteryGroups);
return {
available: Number(batteryCounts[0]?.available) || 0,
charging: Number(batteryCounts[0]?.charging) || 0,
inUse: Number(inUseCounts[0]?.inUse) || 0,
devices: Number(deviceCount[0]?.count) || 0,
batteryGroups: Number(groupCount[0]?.count) || 0,
};
}
async function getRecentBatteries() {
const batteries = await db.query.batteryGroups.findMany({
with: {
brand: true,
type: true,
},
orderBy: (batteryGroups, { desc }) => [desc(batteryGroups.updatedAt)],
limit: 5,
});
return batteries;
}
async function getRecentDevices() {
const devices = await db.query.devices.findMany({
with: {
deviceBatteries: {
with: {
batteryGroup: {
with: {
brand: true,
type: true,
},
},
},
},
},
orderBy: (devices, { desc }) => [desc(devices.updatedAt)],
limit: 5,
});
return devices;
}
export default async function Dashboard() {
const stats = await getStats();
const recentBatteries = await getRecentBatteries();
const recentDevices = await getRecentDevices();
const total = stats.available + stats.charging + stats.inUse;
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-slate-900">Dashboard</h1>
<p className="text-slate-600 mt-1">Overview of your battery inventory</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600">Available</p>
<p className="text-3xl font-bold text-green-600">{stats.available}</p>
</div>
<div className="p-3 bg-green-100 rounded-full">
<Package className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600">In Use</p>
<p className="text-3xl font-bold text-blue-600">{stats.inUse}</p>
</div>
<div className="p-3 bg-blue-100 rounded-full">
<Monitor className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600">Charging</p>
<p className="text-3xl font-bold text-amber-600">{stats.charging}</p>
</div>
<div className="p-3 bg-amber-100 rounded-full">
<BatteryCharging className="w-6 h-6 text-amber-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600">Total</p>
<p className="text-3xl font-bold text-slate-900">{total}</p>
</div>
<div className="p-3 bg-slate-100 rounded-full">
<Battery className="w-6 h-6 text-slate-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Batteries */}
<Card padding="none">
<CardHeader className="px-6 pt-6 pb-0 mb-0">
<div className="flex items-center justify-between">
<CardTitle>Recent Batteries</CardTitle>
<Link
href="/batteries"
className="text-sm text-slate-600 hover:text-slate-900"
>
View all
</Link>
</div>
</CardHeader>
<CardContent className="p-6">
{recentBatteries.length === 0 ? (
<div className="text-center py-8">
<Battery className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500">No batteries yet</p>
<Link
href="/batteries"
className="text-sm text-slate-900 font-medium hover:underline mt-2 inline-block"
>
Add your first battery
</Link>
</div>
) : (
<div className="space-y-3">
{recentBatteries.map((battery) => (
<div
key={battery.id}
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
>
<div>
<p className="font-medium text-slate-900">
{battery.brand.name} {battery.type.name}
</p>
<div className="flex gap-2 mt-1">
<Badge variant="available" size="sm">
{battery.availableCount} available
</Badge>
{battery.chargingCount > 0 && (
<Badge variant="charging" size="sm">
{battery.chargingCount} charging
</Badge>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent Devices */}
<Card padding="none">
<CardHeader className="px-6 pt-6 pb-0 mb-0">
<div className="flex items-center justify-between">
<CardTitle>Devices with Batteries</CardTitle>
<Link
href="/devices"
className="text-sm text-slate-600 hover:text-slate-900"
>
View all
</Link>
</div>
</CardHeader>
<CardContent className="p-6">
{recentDevices.length === 0 ? (
<div className="text-center py-8">
<Monitor className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500">No devices yet</p>
<Link
href="/devices"
className="text-sm text-slate-900 font-medium hover:underline mt-2 inline-block"
>
Add your first device
</Link>
</div>
) : (
<div className="space-y-3">
{recentDevices.map((device) => {
const totalBatteries = device.deviceBatteries.reduce(
(sum, db) => sum + db.quantity,
0
);
return (
<div
key={device.id}
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
>
<div>
<p className="font-medium text-slate-900">{device.name}</p>
{device.description && (
<p className="text-sm text-slate-500">{device.description}</p>
)}
</div>
<Badge variant="inUse">
{totalBatteries} {totalBatteries === 1 ? 'battery' : 'batteries'}
</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
{/* Empty State */}
{stats.batteryGroups === 0 && (
<Card className="text-center py-12">
<Battery className="w-16 h-16 text-slate-300 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-slate-900 mb-2">
Get started with Battery Tracker
</h2>
<p className="text-slate-600 mb-6 max-w-md mx-auto">
Start by adding your batteries to track their status across your devices.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<Link
href="/batteries"
className="inline-flex items-center justify-center px-6 py-3 bg-slate-900 text-white font-medium rounded-lg hover:bg-slate-800 transition-colors"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
Add Batteries
</Link>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,105 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Battery, Home, Monitor, Menu, X } from 'lucide-react';
import { useState } from 'react';
const navItems = [
{ href: '/', label: 'Dashboard', icon: Home },
{ href: '/batteries', label: 'Batteries', icon: Battery },
{ href: '/devices', label: 'Devices', icon: Monitor },
];
export function Navigation() {
const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<nav className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
{/* Logo */}
<div className="flex-shrink-0 flex items-center">
<Battery className="h-8 w-8 text-slate-900" />
<span className="ml-2 text-xl font-bold text-slate-900">
Battery Tracker
</span>
</div>
{/* Desktop navigation */}
<div className="hidden sm:ml-8 sm:flex sm:space-x-4">
{navItems.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={`
inline-flex items-center px-3 py-2 text-sm font-medium rounded-lg
transition-colors duration-200
${
isActive
? 'bg-slate-100 text-slate-900'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}
`}
>
<Icon className="w-4 h-4 mr-2" />
{item.label}
</Link>
);
})}
</div>
</div>
{/* Mobile menu button */}
<div className="flex items-center sm:hidden">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 rounded-lg text-slate-600 hover:text-slate-900 hover:bg-slate-100"
>
{mobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</button>
</div>
</div>
</div>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="sm:hidden border-t border-slate-200">
<div className="px-2 pt-2 pb-3 space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className={`
flex items-center px-3 py-2 text-base font-medium rounded-lg
${
isActive
? 'bg-slate-100 text-slate-900'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}
`}
>
<Icon className="w-5 h-5 mr-3" />
{item.label}
</Link>
);
})}
</div>
</div>
)}
</nav>
);
}

View File

@ -0,0 +1,200 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
import { useRouter } from 'next/navigation';
interface BatteryType {
id: number;
name: string;
}
interface Brand {
id: number;
name: string;
}
interface AddBatteryModalProps {
isOpen: boolean;
onClose: () => void;
types: BatteryType[];
brands: Brand[];
}
export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryModalProps) {
const router = useRouter();
const { showToast } = useToast();
const [loading, setLoading] = useState(false);
const [newBrandName, setNewBrandName] = useState('');
const [showNewBrand, setShowNewBrand] = useState(false);
const [formData, setFormData] = useState({
brandId: '',
typeId: '',
availableCount: '0',
chargingCount: '0',
notes: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
let brandId = formData.brandId;
if (showNewBrand && newBrandName.trim()) {
const brandRes = await fetch('/api/brands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newBrandName.trim() }),
});
if (!brandRes.ok) {
const error = await brandRes.json();
throw new Error(error.error || 'Failed to create brand');
}
const newBrand = await brandRes.json();
brandId = newBrand.id.toString();
}
if (!brandId || !formData.typeId) {
showToast('error', 'Please select a brand and type');
setLoading(false);
return;
}
const res = await fetch('/api/batteries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
brandId: parseInt(brandId),
typeId: parseInt(formData.typeId),
availableCount: parseInt(formData.availableCount) || 0,
chargingCount: parseInt(formData.chargingCount) || 0,
notes: formData.notes || null,
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to add battery');
}
showToast('success', 'Battery added successfully');
onClose();
router.refresh();
setFormData({
brandId: '',
typeId: '',
availableCount: '0',
chargingCount: '0',
notes: '',
});
setNewBrandName('');
setShowNewBrand(false);
} catch (error) {
showToast('error', error instanceof Error ? error.message : 'Failed to add battery');
} finally {
setLoading(false);
}
};
const brandOptions = brands.map((b) => ({ value: b.id, label: b.name }));
const typeOptions = types.map((t) => ({ value: t.id, label: t.name }));
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Add Battery"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? 'Adding...' : 'Add Battery'}
</Button>
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-4">
{!showNewBrand ? (
<div>
<Select
label="Brand"
options={brandOptions}
value={formData.brandId}
onChange={(e) => setFormData({ ...formData, brandId: e.target.value })}
placeholder="Select a brand"
/>
<button
type="button"
onClick={() => setShowNewBrand(true)}
className="mt-1 text-sm text-slate-600 hover:text-slate-900"
>
+ Add new brand
</button>
</div>
) : (
<div>
<Input
label="New Brand Name"
value={newBrandName}
onChange={(e) => setNewBrandName(e.target.value)}
placeholder="e.g., Panasonic, Eneloop"
/>
<button
type="button"
onClick={() => {
setShowNewBrand(false);
setNewBrandName('');
}}
className="mt-1 text-sm text-slate-600 hover:text-slate-900"
>
Select existing brand
</button>
</div>
)}
<Select
label="Type"
options={typeOptions}
value={formData.typeId}
onChange={(e) => setFormData({ ...formData, typeId: e.target.value })}
placeholder="Select battery type"
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Available Count"
type="number"
min="0"
value={formData.availableCount}
onChange={(e) => setFormData({ ...formData, availableCount: e.target.value })}
/>
<Input
label="Charging Count"
type="number"
min="0"
value={formData.chargingCount}
onChange={(e) => setFormData({ ...formData, chargingCount: e.target.value })}
/>
</div>
<Input
label="Notes (optional)"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Any additional notes..."
/>
</form>
</Modal>
);
}

View File

@ -0,0 +1,360 @@
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { useToast } from '@/components/ui/Toast';
import { useRouter } from 'next/navigation';
import { Battery, BatteryCharging, Monitor, Trash2, ArrowRight } from 'lucide-react';
interface Device {
id: number;
name: string;
}
interface BatteryGroup {
id: number;
brandName: string;
typeName: string;
availableCount: number;
chargingCount: number;
inUseCount: number;
notes: string | null;
}
interface BatteryCardProps {
battery: BatteryGroup;
devices: Device[];
}
export function BatteryCard({ battery, devices }: BatteryCardProps) {
const router = useRouter();
const { showToast } = useToast();
const [actionModal, setActionModal] = useState<'charge' | 'available' | 'assign' | 'delete' | null>(null);
const [count, setCount] = useState('1');
const [deviceId, setDeviceId] = useState('');
const [loading, setLoading] = useState(false);
const total = battery.availableCount + battery.chargingCount + battery.inUseCount;
const handleCharge = async () => {
setLoading(true);
try {
const res = await fetch(`/api/batteries/${battery.id}/charge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: parseInt(count) }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error);
}
showToast('success', `${count} battery(s) moved to charging`);
setActionModal(null);
router.refresh();
} catch (error) {
showToast('error', error instanceof Error ? error.message : 'Failed to start charging');
} finally {
setLoading(false);
}
};
const handleAvailable = async () => {
setLoading(true);
try {
const res = await fetch(`/api/batteries/${battery.id}/available`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: parseInt(count) }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error);
}
showToast('success', `${count} battery(s) marked as available`);
setActionModal(null);
router.refresh();
} catch (error) {
showToast('error', error instanceof Error ? error.message : 'Failed to mark as available');
} finally {
setLoading(false);
}
};
const handleAssign = async () => {
setLoading(true);
try {
const res = await fetch(`/api/batteries/${battery.id}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: parseInt(deviceId), quantity: parseInt(count) }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error);
}
showToast('success', `${count} battery(s) assigned to device`);
setActionModal(null);
router.refresh();
} catch (error) {
showToast('error', error instanceof Error ? error.message : 'Failed to assign batteries');
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
setLoading(true);
try {
const res = await fetch(`/api/batteries/${battery.id}`, {
method: 'DELETE',
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error);
}
showToast('success', 'Battery group deleted');
setActionModal(null);
router.refresh();
} catch (error) {
showToast('error', error instanceof Error ? error.message : 'Failed to delete');
} finally {
setLoading(false);
}
};
const deviceOptions = devices.map((d) => ({ value: d.id, label: d.name }));
return (
<>
<Card className="hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-semibold text-slate-900">
{battery.brandName} {battery.typeName}
</h3>
<p className="text-sm text-slate-500">Total: {total} batteries</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setActionModal('delete')}
className="text-slate-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="available">
<Battery className="w-3 h-3 mr-1" />
{battery.availableCount} available
</Badge>
<Badge variant="inUse">
<Monitor className="w-3 h-3 mr-1" />
{battery.inUseCount} in use
</Badge>
<Badge variant="charging">
<BatteryCharging className="w-3 h-3 mr-1" />
{battery.chargingCount} charging
</Badge>
</div>
{battery.notes && (
<p className="text-sm text-slate-500 mb-4">{battery.notes}</p>
)}
<div className="flex flex-wrap gap-2">
{battery.availableCount > 0 && (
<>
<Button
size="sm"
variant="secondary"
onClick={() => {
setCount('1');
setActionModal('charge');
}}
>
Start Charging
</Button>
{devices.length > 0 && (
<Button
size="sm"
variant="secondary"
onClick={() => {
setCount('1');
setDeviceId('');
setActionModal('assign');
}}
>
Assign to Device
</Button>
)}
</>
)}
{battery.chargingCount > 0 && (
<Button
size="sm"
variant="secondary"
onClick={() => {
setCount('1');
setActionModal('available');
}}
>
Mark Available
</Button>
)}
</div>
</Card>
{/* Charge Modal */}
<Modal
isOpen={actionModal === 'charge'}
onClose={() => setActionModal(null)}
title="Start Charging"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setActionModal(null)}>
Cancel
</Button>
<Button onClick={handleCharge} disabled={loading}>
{loading ? 'Moving...' : 'Start Charging'}
</Button>
</div>
}
>
<div className="space-y-4">
<p className="text-slate-600">
Move batteries from available to charging status.
</p>
<Input
label="Number of batteries"
type="number"
min="1"
max={battery.availableCount}
value={count}
onChange={(e) => setCount(e.target.value)}
/>
<div className="flex items-center gap-2 text-sm text-slate-500">
<Badge variant="available">{battery.availableCount}</Badge>
<ArrowRight className="w-4 h-4" />
<Badge variant="charging">{battery.chargingCount}</Badge>
</div>
</div>
</Modal>
{/* Available Modal */}
<Modal
isOpen={actionModal === 'available'}
onClose={() => setActionModal(null)}
title="Mark as Available"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setActionModal(null)}>
Cancel
</Button>
<Button onClick={handleAvailable} disabled={loading}>
{loading ? 'Moving...' : 'Mark Available'}
</Button>
</div>
}
>
<div className="space-y-4">
<p className="text-slate-600">
Move batteries from charging to available status.
</p>
<Input
label="Number of batteries"
type="number"
min="1"
max={battery.chargingCount}
value={count}
onChange={(e) => setCount(e.target.value)}
/>
<div className="flex items-center gap-2 text-sm text-slate-500">
<Badge variant="charging">{battery.chargingCount}</Badge>
<ArrowRight className="w-4 h-4" />
<Badge variant="available">{battery.availableCount}</Badge>
</div>
</div>
</Modal>
{/* Assign Modal */}
<Modal
isOpen={actionModal === 'assign'}
onClose={() => setActionModal(null)}
title="Assign to Device"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setActionModal(null)}>
Cancel
</Button>
<Button onClick={handleAssign} disabled={loading || !deviceId}>
{loading ? 'Assigning...' : 'Assign'}
</Button>
</div>
}
>
<div className="space-y-4">
<p className="text-slate-600">
Assign batteries to a device.
</p>
<Select
label="Device"
options={deviceOptions}
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
placeholder="Select a device"
/>
<Input
label="Number of batteries"
type="number"
min="1"
max={battery.availableCount}
value={count}
onChange={(e) => setCount(e.target.value)}
/>
</div>
</Modal>
{/* Delete Modal */}
<Modal
isOpen={actionModal === 'delete'}
onClose={() => setActionModal(null)}
title="Delete Battery Group"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setActionModal(null)}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete} disabled={loading}>
{loading ? 'Deleting...' : 'Delete'}
</Button>
</div>
}
>
<p className="text-slate-600">
Are you sure you want to delete <strong>{battery.brandName} {battery.typeName}</strong>?
This action cannot be undone.
</p>
{battery.inUseCount > 0 && (
<p className="mt-3 text-amber-600 text-sm">
Note: {battery.inUseCount} batteries are currently in use. You must remove them from devices first.
</p>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,113 @@
'use client';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { BatteryCard } from './BatteryCard';
import { AddBatteryModal } from './AddBatteryModal';
interface BatteryType {
id: number;
name: string;
}
interface Brand {
id: number;
name: string;
}
interface Device {
id: number;
name: string;
}
interface BatteryGroup {
id: number;
brandName: string;
typeName: string;
availableCount: number;
chargingCount: number;
inUseCount: number;
notes: string | null;
}
interface BatteryListClientProps {
batteries: BatteryGroup[];
types: BatteryType[];
brands: Brand[];
devices: Device[];
}
export function BatteryListClient({ batteries, types, brands, devices }: BatteryListClientProps) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [filter, setFilter] = useState<string>('all');
const filteredBatteries = batteries.filter((b) => {
if (filter === 'all') return true;
if (filter === 'available') return b.availableCount > 0;
if (filter === 'charging') return b.chargingCount > 0;
if (filter === 'inUse') return b.inUseCount > 0;
return true;
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900">Batteries</h1>
<p className="text-slate-600 mt-1">Manage your battery inventory</p>
</div>
<Button onClick={() => setIsAddModalOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Add Battery
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
{['all', 'available', 'charging', 'inUse'].map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-slate-900 text-white'
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
}`}
>
{f === 'all' && 'All'}
{f === 'available' && 'Available'}
{f === 'charging' && 'Charging'}
{f === 'inUse' && 'In Use'}
</button>
))}
</div>
{/* Battery Grid */}
{filteredBatteries.length === 0 ? (
<div className="text-center py-12 bg-white rounded-xl border border-slate-200">
<p className="text-slate-500">
{batteries.length === 0
? 'No batteries yet. Add your first battery to get started.'
: 'No batteries match the current filter.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredBatteries.map((battery) => (
<BatteryCard key={battery.id} battery={battery} devices={devices} />
))}
</div>
)}
{/* Add Battery Modal */}
<AddBatteryModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
types={types}
brands={brands}
/>
</div>
);
}

View File

@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
import { useRouter } from 'next/navigation';
interface AddDeviceModalProps {
isOpen: boolean;
onClose: () => void;
}
export function AddDeviceModal({ isOpen, onClose }: AddDeviceModalProps) {
const router = useRouter();
const { showToast } = useToast();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
showToast('error', 'Please enter a device name');
return;
}
setLoading(true);
try {
const res = await fetch('/api/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name.trim(),
description: formData.description.trim() || null,
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to add device');
}
showToast('success', 'Device added successfully');
onClose();
router.refresh();
setFormData({ name: '', description: '' });
} catch (error) {
showToast('error', error instanceof Error ? error.message : 'Failed to add device');
} finally {
setLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Add Device"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? 'Adding...' : 'Add Device'}
</Button>
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Device Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Wireless Keyboard, TV Remote"
/>
<Input
label="Description (optional)"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="e.g., Living room, Office desk"
/>
</form>
</Modal>
);
}

View File

@ -0,0 +1,247 @@
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
import { useRouter } from 'next/navigation';
import { Monitor, Trash2, ArrowRight, Battery } from 'lucide-react';
interface BatteryAssignment {
batteryGroupId: number;
quantity: number;
brandName: string;
typeName: string;
}
interface Device {
id: number;
name: string;
description: string | null;
batteries: BatteryAssignment[];
totalBatteries: number;
}
interface DeviceCardProps {
device: Device;
}
export function DeviceCard({ device }: DeviceCardProps) {
const router = useRouter();
const { showToast } = useToast();
const [actionModal, setActionModal] = useState<'delete' | 'remove' | null>(null);
const [selectedBattery, setSelectedBattery] = useState<BatteryAssignment | null>(null);
const [removeDestination, setRemoveDestination] = useState<'available' | 'charging'>('available');
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
setLoading(true);
try {
const res = await fetch(`/api/devices/${device.id}`, {
method: 'DELETE',
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error);
}
showToast('success', 'Device deleted, batteries returned to available');
setActionModal(null);
router.refresh();
} catch (error) {
showToast('error', error instanceof Error ? error.message : 'Failed to delete');
} finally {
setLoading(false);
}
};
const handleRemoveBatteries = async () => {
if (!selectedBattery) return;
setLoading(true);
try {
const params = new URLSearchParams({
batteryGroupId: selectedBattery.batteryGroupId.toString(),
destination: removeDestination,
});
const res = await fetch(`/api/devices/${device.id}/batteries?${params}`, {
method: 'DELETE',
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error);
}
showToast('success', `Batteries moved to ${removeDestination}`);
setActionModal(null);
setSelectedBattery(null);
router.refresh();
} catch (error) {
showToast('error', error instanceof Error ? error.message : 'Failed to remove batteries');
} finally {
setLoading(false);
}
};
return (
<>
<Card className="hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Monitor className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-slate-900">{device.name}</h3>
{device.description && (
<p className="text-sm text-slate-500">{device.description}</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setActionModal('delete')}
className="text-slate-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{device.batteries.length === 0 ? (
<p className="text-sm text-slate-500 py-2">No batteries assigned</p>
) : (
<div className="space-y-2">
{device.batteries.map((battery) => (
<div
key={battery.batteryGroupId}
className="flex items-center justify-between p-2 bg-slate-50 rounded-lg"
>
<div className="flex items-center gap-2">
<Battery className="w-4 h-4 text-slate-400" />
<span className="text-sm font-medium">
{battery.brandName} {battery.typeName}
</span>
<Badge variant="inUse" size="sm">
x{battery.quantity}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedBattery(battery);
setActionModal('remove');
}}
className="text-slate-400 hover:text-slate-600"
>
Remove
</Button>
</div>
))}
</div>
)}
<div className="mt-4 pt-4 border-t border-slate-100">
<p className="text-sm text-slate-500">
Total: {device.totalBatteries} {device.totalBatteries === 1 ? 'battery' : 'batteries'}
</p>
</div>
</Card>
{/* Delete Device Modal */}
<Modal
isOpen={actionModal === 'delete'}
onClose={() => setActionModal(null)}
title="Delete Device"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setActionModal(null)}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete} disabled={loading}>
{loading ? 'Deleting...' : 'Delete Device'}
</Button>
</div>
}
>
<p className="text-slate-600">
Are you sure you want to delete <strong>{device.name}</strong>?
</p>
{device.totalBatteries > 0 && (
<p className="mt-3 text-slate-600">
The {device.totalBatteries} assigned {device.totalBatteries === 1 ? 'battery' : 'batteries'} will be
returned to available status.
</p>
)}
</Modal>
{/* Remove Batteries Modal */}
<Modal
isOpen={actionModal === 'remove'}
onClose={() => {
setActionModal(null);
setSelectedBattery(null);
}}
title="Remove Batteries"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setActionModal(null)}>
Cancel
</Button>
<Button onClick={handleRemoveBatteries} disabled={loading}>
{loading ? 'Removing...' : 'Remove'}
</Button>
</div>
}
>
{selectedBattery && (
<div className="space-y-4">
<p className="text-slate-600">
Remove <strong>{selectedBattery.quantity} {selectedBattery.brandName} {selectedBattery.typeName}</strong> from {device.name}?
</p>
<div>
<p className="text-sm font-medium text-slate-700 mb-2">Send batteries to:</p>
<div className="flex gap-2">
<button
onClick={() => setRemoveDestination('available')}
className={`flex-1 p-3 rounded-lg border text-sm font-medium transition-colors ${
removeDestination === 'available'
? 'border-green-500 bg-green-50 text-green-700'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
Available (Storage)
</button>
<button
onClick={() => setRemoveDestination('charging')}
className={`flex-1 p-3 rounded-lg border text-sm font-medium transition-colors ${
removeDestination === 'charging'
? 'border-amber-500 bg-amber-50 text-amber-700'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
Charging
</button>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-slate-500">
<Badge variant="inUse">{device.name}</Badge>
<ArrowRight className="w-4 h-4" />
<Badge variant={removeDestination === 'available' ? 'available' : 'charging'}>
{removeDestination === 'available' ? 'Storage' : 'Charger'}
</Badge>
</div>
</div>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { useState } from 'react';
import { Plus, Monitor } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { DeviceCard } from './DeviceCard';
import { AddDeviceModal } from './AddDeviceModal';
interface BatteryAssignment {
batteryGroupId: number;
quantity: number;
brandName: string;
typeName: string;
}
interface Device {
id: number;
name: string;
description: string | null;
batteries: BatteryAssignment[];
totalBatteries: number;
}
interface DeviceListClientProps {
devices: Device[];
}
export function DeviceListClient({ devices }: DeviceListClientProps) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900">Devices</h1>
<p className="text-slate-600 mt-1">Manage your devices and their batteries</p>
</div>
<Button onClick={() => setIsAddModalOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Add Device
</Button>
</div>
{/* Device Grid */}
{devices.length === 0 ? (
<div className="text-center py-12 bg-white rounded-xl border border-slate-200">
<Monitor className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 mb-4">
No devices yet. Add your first device to start tracking battery usage.
</p>
<Button onClick={() => setIsAddModalOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Add Device
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{devices.map((device) => (
<DeviceCard key={device.id} device={device} />
))}
</div>
)}
{/* Add Device Modal */}
<AddDeviceModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
/>
</div>
);
}

View File

@ -0,0 +1,42 @@
import { HTMLAttributes, forwardRef } from 'react';
type BadgeVariant = 'available' | 'inUse' | 'charging' | 'default';
type BadgeSize = 'sm' | 'md';
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
size?: BadgeSize;
}
const variantStyles: Record<BadgeVariant, string> = {
available: 'bg-green-100 text-green-800',
inUse: 'bg-blue-100 text-blue-800',
charging: 'bg-amber-100 text-amber-800',
default: 'bg-slate-100 text-slate-800',
};
const sizeStyles: Record<BadgeSize, string> = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
};
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
({ className = '', variant = 'default', size = 'md', children, ...props }, ref) => {
return (
<span
ref={ref}
className={`
inline-flex items-center font-medium rounded-full
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
{...props}
>
{children}
</span>
);
}
);
Badge.displayName = 'Badge';

View File

@ -0,0 +1,47 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-slate-900 text-white hover:bg-slate-800 focus:ring-slate-500',
secondary: 'bg-white text-slate-900 border border-slate-300 hover:bg-slate-50 focus:ring-slate-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'bg-transparent text-slate-600 hover:bg-slate-100 focus:ring-slate-500',
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className = '', variant = 'primary', size = 'md', disabled, children, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled}
className={`
inline-flex items-center justify-center font-medium rounded-lg
transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = 'Button';

View File

@ -0,0 +1,68 @@
import { HTMLAttributes, forwardRef } from 'react';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg';
}
const paddingStyles = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className = '', padding = 'md', children, ...props }, ref) => {
return (
<div
ref={ref}
className={`
bg-white rounded-xl shadow-sm border border-slate-200
${paddingStyles[padding]}
${className}
`}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={`mb-4 ${className}`} {...props}>
{children}
</div>
);
}
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className = '', children, ...props }, ref) => {
return (
<h3 ref={ref} className={`text-lg font-semibold text-slate-900 ${className}`} {...props}>
{children}
</h3>
);
}
);
CardTitle.displayName = 'CardTitle';
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={className} {...props}>
{children}
</div>
);
}
);
CardContent.displayName = 'CardContent';

View File

@ -0,0 +1,43 @@
import { InputHTMLAttributes, forwardRef } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', label, error, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-slate-700 mb-1"
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`
w-full px-3 py-2 rounded-lg border border-slate-300
text-slate-900 placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-slate-500 focus:border-transparent
disabled:bg-slate-50 disabled:text-slate-500 disabled:cursor-not-allowed
${error ? 'border-red-500 focus:ring-red-500' : ''}
${className}
`}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@ -0,0 +1,60 @@
'use client';
import { Fragment, ReactNode } from 'react';
import { X } from 'lucide-react';
import { Button } from './Button';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
}
export function Modal({ isOpen, onClose, title, children, footer }: ModalProps) {
if (!isOpen) return null;
return (
<Fragment>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40 transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="bg-white rounded-xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="p-1 -mr-1"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="px-6 py-4 overflow-y-auto flex-1">
{children}
</div>
{/* Footer */}
{footer && (
<div className="px-6 py-4 border-t border-slate-200 bg-slate-50">
{footer}
</div>
)}
</div>
</div>
</Fragment>
);
}

View File

@ -0,0 +1,61 @@
import { SelectHTMLAttributes, forwardRef } from 'react';
interface SelectOption {
value: string | number;
label: string;
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: SelectOption[];
placeholder?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className = '', label, error, options, placeholder, id, ...props }, ref) => {
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-');
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-slate-700 mb-1"
>
{label}
</label>
)}
<select
ref={ref}
id={selectId}
className={`
w-full px-3 py-2 rounded-lg border border-slate-300
text-slate-900 bg-white
focus:outline-none focus:ring-2 focus:ring-slate-500 focus:border-transparent
disabled:bg-slate-50 disabled:text-slate-500 disabled:cursor-not-allowed
${error ? 'border-red-500 focus:ring-red-500' : ''}
${className}
`}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Select.displayName = 'Select';

View File

@ -0,0 +1,94 @@
'use client';
import { createContext, useContext, useState, ReactNode, useCallback } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
type: ToastType;
message: string;
}
interface ToastContextType {
showToast: (type: ToastType, message: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
const icons: Record<ToastType, typeof CheckCircle> = {
success: CheckCircle,
error: AlertCircle,
info: Info,
};
const styles: Record<ToastType, string> = {
success: 'bg-green-50 text-green-800 border-green-200',
error: 'bg-red-50 text-red-800 border-red-200',
info: 'bg-blue-50 text-blue-800 border-blue-200',
};
const iconStyles: Record<ToastType, string> = {
success: 'text-green-500',
error: 'text-red-500',
info: 'text-blue-500',
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((type: ToastType, message: string) => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, type, message }]);
// Auto dismiss after 4 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast container */}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => {
const Icon = icons[toast.type];
return (
<div
key={toast.id}
className={`
flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg
animate-in slide-in-from-right-5 duration-300
${styles[toast.type]}
`}
>
<Icon className={`w-5 h-5 flex-shrink-0 ${iconStyles[toast.type]}`} />
<p className="text-sm font-medium flex-1">{toast.message}</p>
<button
onClick={() => dismissToast(toast.id)}
className="p-1 hover:opacity-70 transition-opacity"
>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}

View File

@ -0,0 +1,7 @@
export { Button } from './Button';
export { Card, CardHeader, CardTitle, CardContent } from './Card';
export { Badge } from './Badge';
export { Input } from './Input';
export { Select } from './Select';
export { Modal } from './Modal';
export { ToastProvider, useToast } from './Toast';

14
src/lib/db/index.ts Normal file
View File

@ -0,0 +1,14 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import path from 'path';
const dbPath = path.join(process.cwd(), 'data', 'battery_tracker.db');
const sqlite = new Database(dbPath);
// Enable foreign keys
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export { schema };

99
src/lib/db/schema.ts Normal file
View File

@ -0,0 +1,99 @@
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { relations, sql } from 'drizzle-orm';
// Battery types (AA, AAA, 18650, etc.)
export const batteryTypes = sqliteTable('battery_types', {
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 }),
name: text('name').notNull().unique(),
createdAt: text('created_at').default(sql`(datetime('now'))`),
});
// Battery groups (Brand + Type 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),
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),
]);
// Devices
export const devices = sqliteTable('devices', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
description: text('description'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
});
// Device-Battery assignments
export const deviceBatteries = sqliteTable('device_batteries', {
id: integer('id').primaryKey({ autoIncrement: true }),
deviceId: integer('device_id').notNull().references(() => devices.id, { onDelete: 'cascade' }),
batteryGroupId: integer('battery_group_id').notNull().references(() => batteryGroups.id),
quantity: integer('quantity').notNull().default(1),
assignedAt: text('assigned_at').default(sql`(datetime('now'))`),
}, (table) => [
uniqueIndex('device_battery_idx').on(table.deviceId, table.batteryGroupId),
]);
// Relations
export const batteryTypesRelations = relations(batteryTypes, ({ many }) => ({
batteryGroups: many(batteryGroups),
}));
export const brandsRelations = relations(brands, ({ many }) => ({
batteryGroups: many(batteryGroups),
}));
export const batteryGroupsRelations = relations(batteryGroups, ({ one, many }) => ({
brand: one(brands, {
fields: [batteryGroups.brandId],
references: [brands.id],
}),
type: one(batteryTypes, {
fields: [batteryGroups.typeId],
references: [batteryTypes.id],
}),
deviceBatteries: many(deviceBatteries),
}));
export const devicesRelations = relations(devices, ({ many }) => ({
deviceBatteries: many(deviceBatteries),
}));
export const deviceBatteriesRelations = relations(deviceBatteries, ({ one }) => ({
device: one(devices, {
fields: [deviceBatteries.deviceId],
references: [devices.id],
}),
batteryGroup: one(batteryGroups, {
fields: [deviceBatteries.batteryGroupId],
references: [batteryGroups.id],
}),
}));
// Type exports
export type BatteryType = typeof batteryTypes.$inferSelect;
export type NewBatteryType = typeof batteryTypes.$inferInsert;
export type Brand = typeof brands.$inferSelect;
export type NewBrand = typeof brands.$inferInsert;
export type BatteryGroup = typeof batteryGroups.$inferSelect;
export type NewBatteryGroup = typeof batteryGroups.$inferInsert;
export type Device = typeof devices.$inferSelect;
export type NewDevice = typeof devices.$inferInsert;
export type DeviceBattery = typeof deviceBatteries.$inferSelect;
export type NewDeviceBattery = typeof deviceBatteries.$inferInsert;

41
src/lib/db/seed.ts Normal file
View File

@ -0,0 +1,41 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import * as schema from './schema';
import path from 'path';
import fs from 'fs';
const dbPath = path.join(process.cwd(), 'data', 'battery_tracker.db');
// Ensure data directory exists
const dataDir = path.dirname(dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const sqlite = new Database(dbPath);
sqlite.pragma('foreign_keys = ON');
const db = drizzle(sqlite, { schema });
// Run migrations
console.log('Running migrations...');
migrate(db, { migrationsFolder: './drizzle' });
console.log('Migrations complete.');
// Seed default battery types
const defaultTypes = ['AA', 'AAA', '18650', 'CR2032', '9V', 'C', 'D', '14500', '16340', '21700'];
console.log('Seeding default battery types...');
for (const typeName of defaultTypes) {
try {
db.insert(schema.batteryTypes).values({ name: typeName, isCustom: false }).run();
console.log(` Added: ${typeName}`);
} catch {
// Already exists, skip
console.log(` Skipped (exists): ${typeName}`);
}
}
console.log('Seed complete!');
sqlite.close();