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:
parent
289f83ad5c
commit
e7ca93f510
|
|
@ -39,3 +39,6 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# database
|
||||
/data/*.db
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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'))
|
||||
);
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1768838495290,
|
||||
"tag": "0000_blue_anthem",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
>
|
||||
<ToastProvider>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
329
src/app/page.tsx
329
src/app/page.tsx
|
|
@ -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"
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
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 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"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
Add Batteries
|
||||
</Link>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
Loading…
Reference in New Issue