MVP
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
POSTGRES_DB=arsenal_iq
|
||||||
|
POSTGRES_USER=arsenal
|
||||||
|
POSTGRES_PASSWORD=change_me
|
||||||
|
NODE_ENV=development
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
|
# Production-only Traefik settings
|
||||||
|
TRAEFIK_NETWORK=traefik_proxy
|
||||||
|
TRAEFIK_ENTRYPOINT=websecure
|
||||||
|
TRAEFIK_WEB_HOST=arsenal.example.com
|
||||||
|
TRAEFIK_API_HOST=api.arsenal.example.com
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
data/postgres/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
npm-debug.log*
|
||||||
@@ -1,9 +1,87 @@
|
|||||||
# Arsenal_IQ
|
# Arsenal IQ
|
||||||
|
|
||||||
Repo for Arsenal_IQ, a CRM-like tool to manage your guns and ammo
|
Arsenal IQ is a self-hosted ERP-style inventory app for recording firearms and ammunition. It tracks serial numbers, acquisition cost, estimated value, ammunition on hand by caliber, and ammo cost, with a React frontend and a Docker Compose deployment model that supports both local development and Traefik-backed production.
|
||||||
|
|
||||||
## License
|
## Stack
|
||||||
|
|
||||||
Licensed under the PolyForm Noncommercial License.
|
- Frontend: React + TypeScript + Vite
|
||||||
Commercial use requires a separate license.
|
- Backend: Node.js + Express + TypeScript
|
||||||
``
|
- Database: PostgreSQL 16
|
||||||
|
- Runtime: Docker Compose
|
||||||
|
- Proxy: optional Traefik production override
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Firearm registry with manufacturer, model, serial number, caliber, category, storage location, and cost basis
|
||||||
|
- Ammunition lot tracking with caliber, grain, quantity on hand, vendor, cost per round, and reorder thresholds
|
||||||
|
- Dashboard summaries for inventory counts, invested value, and low-stock alerts
|
||||||
|
- Seed data so the UI is useful immediately after first startup
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Copy the environment file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create the shared Traefik Docker network if you do not already have it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create traefik_proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build and start the local development stack.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local endpoints
|
||||||
|
|
||||||
|
- Frontend: `http://localhost:3000`
|
||||||
|
- API: `http://localhost:5000/api`
|
||||||
|
- Health check: `http://localhost:5000/health`
|
||||||
|
- PostgreSQL: `localhost:5432`
|
||||||
|
|
||||||
|
## Development storage
|
||||||
|
|
||||||
|
This project now uses local bind mounts instead of named Docker volumes:
|
||||||
|
|
||||||
|
- PostgreSQL data: `./data/postgres`
|
||||||
|
- Backend dependencies: `./backend/node_modules`
|
||||||
|
- Frontend dependencies: `./frontend/node_modules`
|
||||||
|
|
||||||
|
On first start, the backend and frontend containers will install dependencies into those local `node_modules` folders before launching.
|
||||||
|
|
||||||
|
## Production with Traefik
|
||||||
|
|
||||||
|
For production, use the dedicated production compose file so Traefik labels and external networking are only enabled there.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure these values in `.env` for your production domain names:
|
||||||
|
|
||||||
|
```env
|
||||||
|
TRAEFIK_NETWORK=traefik_proxy
|
||||||
|
TRAEFIK_ENTRYPOINT=websecure
|
||||||
|
TRAEFIK_WEB_HOST=arsenal.example.com
|
||||||
|
TRAEFIK_API_HOST=api.arsenal.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
If the frontend should call the API through Traefik in production, set:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=https://api.arsenal.example.com/api
|
||||||
|
FRONTEND_URL=https://arsenal.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## API routes
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /api`
|
||||||
|
- `GET /api/dashboard`
|
||||||
|
- `GET /api/firearms`
|
||||||
|
- `GET /api/ammo`
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calibers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS firearms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
manufacturer VARCHAR(120) NOT NULL,
|
||||||
|
model VARCHAR(120) NOT NULL,
|
||||||
|
category VARCHAR(80) NOT NULL,
|
||||||
|
caliber VARCHAR(40) NOT NULL,
|
||||||
|
serial_number VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
acquired_on DATE,
|
||||||
|
image_url TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ammo_inventory (
|
||||||
|
caliber_id UUID PRIMARY KEY REFERENCES calibers(id) ON DELETE CASCADE,
|
||||||
|
rounds_on_hand INT NOT NULL DEFAULT 0 CHECK (rounds_on_hand >= 0),
|
||||||
|
cost_per_round NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO calibers (name, is_default, is_active)
|
||||||
|
VALUES
|
||||||
|
('9mm', TRUE, TRUE),
|
||||||
|
('.22 LR', TRUE, TRUE),
|
||||||
|
('5.56 NATO', TRUE, TRUE),
|
||||||
|
('.308 Win', TRUE, TRUE),
|
||||||
|
('12 Gauge', TRUE, TRUE),
|
||||||
|
('.45 ACP', TRUE, TRUE)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round)
|
||||||
|
SELECT id, 0, 0
|
||||||
|
FROM calibers
|
||||||
|
ON CONFLICT (caliber_id) DO NOTHING;
|
||||||
Generated
+1734
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "arsenal-iq-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend for Arsenal IQ firearm and ammunition inventory",
|
||||||
|
"main": "dist/app.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --import tsx src/app.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/app.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "2.8.5",
|
||||||
|
"dotenv": "16.4.5",
|
||||||
|
"express": "4.18.2",
|
||||||
|
"express-rate-limit": "7.1.5",
|
||||||
|
"helmet": "7.1.0",
|
||||||
|
"morgan": "1.10.0",
|
||||||
|
"pg": "8.11.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "2.8.17",
|
||||||
|
"@types/express": "4.17.21",
|
||||||
|
"@types/morgan": "1.9.9",
|
||||||
|
"@types/node": "20.10.6",
|
||||||
|
"tsx": "4.7.0",
|
||||||
|
"typescript": "5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import express from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = Number(process.env.PORT ?? 5000);
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: databaseUrl });
|
||||||
|
|
||||||
|
const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP'];
|
||||||
|
|
||||||
|
const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:3000')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: allowedOrigins,
|
||||||
|
credentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 250,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
type DashboardSummary = {
|
||||||
|
totalFirearms: number;
|
||||||
|
totalAmmoRounds: number;
|
||||||
|
firearmsInvestment: string;
|
||||||
|
ammoInvestment: string;
|
||||||
|
configuredCalibers: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FirearmRow = {
|
||||||
|
id: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
category: string;
|
||||||
|
caliber: string;
|
||||||
|
serial_number: string;
|
||||||
|
purchase_price: string;
|
||||||
|
acquired_on: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CaliberRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AmmoInventoryRow = {
|
||||||
|
caliber_id: string;
|
||||||
|
caliber_name: string;
|
||||||
|
rounds_on_hand: number;
|
||||||
|
cost_per_round: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FirearmInput = {
|
||||||
|
manufacturer?: unknown;
|
||||||
|
model?: unknown;
|
||||||
|
category?: unknown;
|
||||||
|
caliber?: unknown;
|
||||||
|
serialNumber?: unknown;
|
||||||
|
purchasePrice?: unknown;
|
||||||
|
acquiredOn?: unknown;
|
||||||
|
imageUrl?: unknown;
|
||||||
|
notes?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CaliberInput = {
|
||||||
|
name?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AmmoAdjustmentInput = {
|
||||||
|
rounds?: unknown;
|
||||||
|
costPerRound?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: string | number | null): number => Number(value ?? 0);
|
||||||
|
|
||||||
|
const getString = (value: unknown, fieldName: string, required = true): string => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
if (required) {
|
||||||
|
throw new Error(`${fieldName} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed && required) {
|
||||||
|
throw new Error(`${fieldName} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionalString = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNumber = (value: unknown, fieldName: string): number => {
|
||||||
|
const parsed = typeof value === 'number' ? value : Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw new Error(`${fieldName} must be a valid number`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFirearm = (row: FirearmRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
manufacturer: row.manufacturer,
|
||||||
|
model: row.model,
|
||||||
|
category: row.category,
|
||||||
|
caliber: row.caliber,
|
||||||
|
serialNumber: row.serial_number,
|
||||||
|
purchasePrice: formatCurrency(row.purchase_price),
|
||||||
|
acquiredOn: row.acquired_on,
|
||||||
|
imageUrl: row.image_url,
|
||||||
|
notes: row.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeCaliber = (row: CaliberRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
isDefault: row.is_default,
|
||||||
|
isActive: row.is_active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeAmmoInventory = (row: AmmoInventoryRow) => ({
|
||||||
|
caliberId: row.caliber_id,
|
||||||
|
caliber: row.caliber_name,
|
||||||
|
roundsOnHand: row.rounds_on_hand,
|
||||||
|
costPerRound: formatCurrency(row.cost_per_round),
|
||||||
|
totalValue: formatCurrency(row.cost_per_round) * row.rounds_on_hand,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureSchema = async () => {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS calibers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS firearms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
manufacturer VARCHAR(120) NOT NULL,
|
||||||
|
model VARCHAR(120) NOT NULL,
|
||||||
|
category VARCHAR(80) NOT NULL,
|
||||||
|
caliber VARCHAR(40) NOT NULL,
|
||||||
|
serial_number VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
acquired_on DATE,
|
||||||
|
image_url TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ammo_inventory (
|
||||||
|
caliber_id UUID PRIMARY KEY REFERENCES calibers(id) ON DELETE CASCADE,
|
||||||
|
rounds_on_hand INT NOT NULL DEFAULT 0 CHECK (rounds_on_hand >= 0),
|
||||||
|
cost_per_round NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE firearms ADD COLUMN IF NOT EXISTS image_url TEXT;
|
||||||
|
ALTER TABLE firearms ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||||
|
ALTER TABLE firearms ADD COLUMN IF NOT EXISTS acquired_on DATE;
|
||||||
|
ALTER TABLE firearms ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_firearms_caliber ON firearms(caliber);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_calibers_active ON calibers(is_active);
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const caliber of defaultCalibers) {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO calibers (name, is_default, is_active)
|
||||||
|
VALUES ($1, TRUE, TRUE)
|
||||||
|
ON CONFLICT (name) DO UPDATE
|
||||||
|
SET is_default = TRUE
|
||||||
|
`,
|
||||||
|
[caliber],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
DELETE FROM firearms
|
||||||
|
WHERE serial_number IN ('G19-EXAMPLE-001', 'R1022-EXAMPLE-002', 'M590A1-EXAMPLE-003')
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round)
|
||||||
|
SELECT c.id, 0, 0
|
||||||
|
FROM calibers c
|
||||||
|
WHERE c.is_active = TRUE
|
||||||
|
ON CONFLICT (caliber_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/health', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT NOW() AS now');
|
||||||
|
res.json({ status: 'ok', database: 'connected', timestamp: result.rows[0].now });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
name: 'Arsenal IQ API',
|
||||||
|
version: '2.0.0',
|
||||||
|
resources: ['/api/dashboard', '/api/firearms', '/api/calibers', '/api/ammo'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/dashboard', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [summaryResult, firearmsResult, calibersResult, ammoResult] = await Promise.all([
|
||||||
|
pool.query<DashboardSummary>(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM firearms) AS "totalFirearms",
|
||||||
|
COALESCE((SELECT SUM(rounds_on_hand)::int FROM ammo_inventory), 0) AS "totalAmmoRounds",
|
||||||
|
COALESCE((SELECT SUM(purchase_price) FROM firearms), 0) AS "firearmsInvestment",
|
||||||
|
COALESCE((SELECT SUM(rounds_on_hand * cost_per_round) FROM ammo_inventory), 0) AS "ammoInvestment",
|
||||||
|
(SELECT COUNT(*)::int FROM calibers WHERE is_active = TRUE) AS "configuredCalibers"
|
||||||
|
`),
|
||||||
|
pool.query<FirearmRow>(`
|
||||||
|
SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||||
|
FROM firearms
|
||||||
|
ORDER BY acquired_on DESC NULLS LAST, created_at DESC
|
||||||
|
`),
|
||||||
|
pool.query<CaliberRow>(`
|
||||||
|
SELECT id, name, is_default, is_active
|
||||||
|
FROM calibers
|
||||||
|
ORDER BY is_active DESC, is_default DESC, name ASC
|
||||||
|
`),
|
||||||
|
pool.query<AmmoInventoryRow>(`
|
||||||
|
SELECT
|
||||||
|
ai.caliber_id,
|
||||||
|
c.name AS caliber_name,
|
||||||
|
ai.rounds_on_hand,
|
||||||
|
ai.cost_per_round
|
||||||
|
FROM ammo_inventory ai
|
||||||
|
INNER JOIN calibers c ON c.id = ai.caliber_id
|
||||||
|
WHERE c.is_active = TRUE
|
||||||
|
ORDER BY c.name ASC
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summary = summaryResult.rows[0];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
summary: {
|
||||||
|
totalFirearms: summary.totalFirearms,
|
||||||
|
totalAmmoRounds: summary.totalAmmoRounds,
|
||||||
|
firearmsInvestment: formatCurrency(summary.firearmsInvestment),
|
||||||
|
ammoInvestment: formatCurrency(summary.ammoInvestment),
|
||||||
|
configuredCalibers: summary.configuredCalibers,
|
||||||
|
},
|
||||||
|
firearms: firearmsResult.rows.map(normalizeFirearm),
|
||||||
|
calibers: calibersResult.rows.map(normalizeCaliber),
|
||||||
|
ammoInventory: ammoResult.rows.map(normalizeAmmoInventory),
|
||||||
|
defaultCalibers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/firearms', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query<FirearmRow>(`
|
||||||
|
SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||||
|
FROM firearms
|
||||||
|
ORDER BY acquired_on DESC NULLS LAST, created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(result.rows.map(normalizeFirearm));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/firearms', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as FirearmInput;
|
||||||
|
const manufacturer = getString(body.manufacturer, 'manufacturer');
|
||||||
|
const model = getString(body.model, 'model');
|
||||||
|
const category = getString(body.category, 'category');
|
||||||
|
const caliber = getString(body.caliber, 'caliber');
|
||||||
|
const serialNumber = getString(body.serialNumber, 'serialNumber');
|
||||||
|
const purchasePrice = getNumber(body.purchasePrice, 'purchasePrice');
|
||||||
|
const acquiredOn = getOptionalString(body.acquiredOn);
|
||||||
|
const imageUrl = getOptionalString(body.imageUrl);
|
||||||
|
const notes = getOptionalString(body.notes);
|
||||||
|
|
||||||
|
const result = await pool.query<FirearmRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO firearms (
|
||||||
|
manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||||
|
`,
|
||||||
|
[manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(normalizeFirearm(result.rows[0]));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/firearms/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as FirearmInput;
|
||||||
|
const manufacturer = getString(body.manufacturer, 'manufacturer');
|
||||||
|
const model = getString(body.model, 'model');
|
||||||
|
const category = getString(body.category, 'category');
|
||||||
|
const caliber = getString(body.caliber, 'caliber');
|
||||||
|
const serialNumber = getString(body.serialNumber, 'serialNumber');
|
||||||
|
const purchasePrice = getNumber(body.purchasePrice, 'purchasePrice');
|
||||||
|
const acquiredOn = getOptionalString(body.acquiredOn);
|
||||||
|
const imageUrl = getOptionalString(body.imageUrl);
|
||||||
|
const notes = getOptionalString(body.notes);
|
||||||
|
|
||||||
|
const result = await pool.query<FirearmRow>(
|
||||||
|
`
|
||||||
|
UPDATE firearms
|
||||||
|
SET
|
||||||
|
manufacturer = $2,
|
||||||
|
model = $3,
|
||||||
|
category = $4,
|
||||||
|
caliber = $5,
|
||||||
|
serial_number = $6,
|
||||||
|
purchase_price = $7,
|
||||||
|
acquired_on = $8,
|
||||||
|
image_url = $9,
|
||||||
|
notes = $10,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes
|
||||||
|
`,
|
||||||
|
[req.params.id, manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
res.status(404).json({ error: 'Firearm not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(normalizeFirearm(result.rows[0]));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/firearms/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('DELETE FROM firearms WHERE id = $1', [req.params.id]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
res.status(404).json({ error: 'Firearm not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/calibers', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query<CaliberRow>(`
|
||||||
|
SELECT id, name, is_default, is_active
|
||||||
|
FROM calibers
|
||||||
|
ORDER BY is_active DESC, is_default DESC, name ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
configured: result.rows.filter((row) => row.is_active).map(normalizeCaliber),
|
||||||
|
availableDefaults: defaultCalibers.filter(
|
||||||
|
(name) => !result.rows.some((row) => row.name === name && row.is_active),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/calibers', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as CaliberInput;
|
||||||
|
const name = getString(body.name, 'name');
|
||||||
|
|
||||||
|
const result = await pool.query<CaliberRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO calibers (name, is_default, is_active)
|
||||||
|
VALUES ($1, $2, TRUE)
|
||||||
|
ON CONFLICT (name) DO UPDATE
|
||||||
|
SET is_active = TRUE
|
||||||
|
RETURNING id, name, is_default, is_active
|
||||||
|
`,
|
||||||
|
[name, defaultCalibers.includes(name)],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round)
|
||||||
|
VALUES ($1, 0, 0)
|
||||||
|
ON CONFLICT (caliber_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
[result.rows[0].id],
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(normalizeCaliber(result.rows[0]));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/calibers/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const isActive = Boolean(req.body?.isActive);
|
||||||
|
const result = await pool.query<CaliberRow>(
|
||||||
|
`
|
||||||
|
UPDATE calibers
|
||||||
|
SET is_active = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, name, is_default, is_active
|
||||||
|
`,
|
||||||
|
[req.params.id, isActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
res.status(404).json({ error: 'Caliber not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO ammo_inventory (caliber_id, rounds_on_hand, cost_per_round)
|
||||||
|
VALUES ($1, 0, 0)
|
||||||
|
ON CONFLICT (caliber_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
[req.params.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(normalizeCaliber(result.rows[0]));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/ammo', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query<AmmoInventoryRow>(`
|
||||||
|
SELECT
|
||||||
|
ai.caliber_id,
|
||||||
|
c.name AS caliber_name,
|
||||||
|
ai.rounds_on_hand,
|
||||||
|
ai.cost_per_round
|
||||||
|
FROM ammo_inventory ai
|
||||||
|
INNER JOIN calibers c ON c.id = ai.caliber_id
|
||||||
|
WHERE c.is_active = TRUE
|
||||||
|
ORDER BY c.name ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(result.rows.map(normalizeAmmoInventory));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/ammo/:caliberId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as AmmoAdjustmentInput;
|
||||||
|
const rounds = getNumber(body.rounds, 'rounds');
|
||||||
|
const costPerRound = getNumber(body.costPerRound, 'costPerRound');
|
||||||
|
|
||||||
|
const result = await pool.query<AmmoInventoryRow>(
|
||||||
|
`
|
||||||
|
UPDATE ammo_inventory
|
||||||
|
SET
|
||||||
|
rounds_on_hand = GREATEST(0, rounds_on_hand + $2),
|
||||||
|
cost_per_round = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE caliber_id = $1
|
||||||
|
RETURNING
|
||||||
|
caliber_id,
|
||||||
|
(
|
||||||
|
SELECT name
|
||||||
|
FROM calibers
|
||||||
|
WHERE id = ammo_inventory.caliber_id
|
||||||
|
) AS caliber_name,
|
||||||
|
rounds_on_hand,
|
||||||
|
cost_per_round
|
||||||
|
`,
|
||||||
|
[req.params.caliberId, rounds, costPerRound],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
res.status(404).json({ error: 'Ammo inventory not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(normalizeAmmoInventory(result.rows[0]));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((_req, res) => {
|
||||||
|
res.status(404).json({ error: 'Route not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : error.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void ensureSchema()
|
||||||
|
.then(() => {
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Arsenal IQ API listening on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to initialize schema', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ES2020",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: arsenaliq-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-arsenal_iq}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-arsenal}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-arsenal_dev_password}
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
- ./backend/database/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-arsenal} -d ${POSTGRES_DB:-arsenal_iq}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: arsenaliq-backend
|
||||||
|
environment:
|
||||||
|
PORT: 5000
|
||||||
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq}
|
||||||
|
FRONTEND_URL: ${FRONTEND_URL:-https://arsenal.example.com}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
command: >
|
||||||
|
sh -c "npm install --legacy-peer-deps &&
|
||||||
|
npm run dev"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./backend/node_modules:/app/node_modules
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}
|
||||||
|
- traefik.http.routers.arsenaliq-api.rule=Host(`${TRAEFIK_API_HOST:-api.arsenal.local}`)
|
||||||
|
- traefik.http.routers.arsenaliq-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||||
|
- traefik.http.routers.arsenaliq-api.tls=true
|
||||||
|
- traefik.http.services.arsenaliq-api.loadbalancer.server.port=5000
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
- traefik_proxy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: arsenaliq-frontend
|
||||||
|
environment:
|
||||||
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.arsenal.example.com/api}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
command: >
|
||||||
|
sh -c "npm install --legacy-peer-deps &&
|
||||||
|
npm run dev -- --host"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- ./frontend/node_modules:/app/node_modules
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=${TRAEFIK_NETWORK:-traefik_proxy}
|
||||||
|
- traefik.http.routers.arsenaliq-web.rule=Host(`${TRAEFIK_WEB_HOST:-arsenal.local}`)
|
||||||
|
- traefik.http.routers.arsenaliq-web.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
||||||
|
- traefik.http.routers.arsenaliq-web.tls=true
|
||||||
|
- traefik.http.services.arsenaliq-web.loadbalancer.server.port=3000
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
- traefik_proxy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app:
|
||||||
|
driver: bridge
|
||||||
|
traefik_proxy:
|
||||||
|
external: true
|
||||||
|
name: ${TRAEFIK_NETWORK:-traefik_proxy}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: arsenaliq-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-arsenal_iq}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-arsenal}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-arsenal_dev_password}
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
- ./backend/database/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-arsenal} -d ${POSTGRES_DB:-arsenal_iq}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: arsenaliq-backend
|
||||||
|
environment:
|
||||||
|
PORT: 5000
|
||||||
|
NODE_ENV: ${NODE_ENV:-development}
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-arsenal}:${POSTGRES_PASSWORD:-arsenal_dev_password}@postgres:5432/${POSTGRES_DB:-arsenal_iq}
|
||||||
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
command: >
|
||||||
|
sh -c "npm install --legacy-peer-deps &&
|
||||||
|
npm run dev"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./backend/node_modules:/app/node_modules
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: arsenaliq-frontend
|
||||||
|
environment:
|
||||||
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
command: >
|
||||||
|
sh -c "npm install --legacy-peer-deps &&
|
||||||
|
npm run dev -- --host"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- ./frontend/node_modules:/app/node_modules
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Arsenal IQ</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2702
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "arsenal-iq-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "0.263.1",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "18.2.37",
|
||||||
|
"@types/react-dom": "18.2.15",
|
||||||
|
"@vitejs/plugin-react": "4.2.1",
|
||||||
|
"autoprefixer": "10.4.16",
|
||||||
|
"postcss": "8.4.32",
|
||||||
|
"tailwindcss": "3.4.1",
|
||||||
|
"typescript": "5.3.3",
|
||||||
|
"vite": "5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Home from './pages/Home';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <Home />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 80" role="img" aria-label="AR-pattern rifle silhouette">
|
||||||
|
<rect width="160" height="80" rx="16" fill="#375544"/>
|
||||||
|
<g fill="#fff8ef">
|
||||||
|
<rect x="18" y="37" width="59" height="7" rx="2"/>
|
||||||
|
<rect x="67" y="34" width="17" height="8" rx="1.5"/>
|
||||||
|
<rect x="81" y="31" width="31" height="6" rx="1.5"/>
|
||||||
|
<rect x="107" y="33" width="22" height="4" rx="1.5"/>
|
||||||
|
<rect x="118" y="31" width="20" height="3" rx="1.5"/>
|
||||||
|
<rect x="71" y="24" width="12" height="10" rx="1.5"/>
|
||||||
|
<rect x="60" y="24" width="12" height="3" rx="1.5"/>
|
||||||
|
<path d="M56 44h14l6 21h-9l-4-15h-7z"/>
|
||||||
|
<path d="M45 44h10l2 15h-6l-2-9h-3z"/>
|
||||||
|
<path d="M30 37l12-10h16l-9 10z"/>
|
||||||
|
<path d="M16 39l13-4v11l-13-4z"/>
|
||||||
|
<path d="M96 37h16l3 6h-19z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 805 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80" role="img" aria-label="Generic firearm placeholder">
|
||||||
|
<rect width="120" height="80" rx="16" fill="#555"/>
|
||||||
|
<circle cx="60" cy="30" r="14" stroke="#fff8ef" stroke-width="6" fill="none"/>
|
||||||
|
<path d="M60 14v32M44 30h32" stroke="#fff8ef" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 80" role="img" aria-label="Handgun silhouette">
|
||||||
|
<rect width="140" height="80" rx="16" fill="#5f4630"/>
|
||||||
|
<g fill="#fff8ef">
|
||||||
|
<path d="M24 43h42l9-6h20v-8l-11-4H66l-9-9H35l-11 11z"/>
|
||||||
|
<path d="M39 43h16l2 22c0 2-2 4-4 4h-6c-2 0-4-2-4-4z"/>
|
||||||
|
<rect x="24" y="40" width="15" height="4" rx="2"/>
|
||||||
|
<rect x="73" y="23" width="21" height="3" rx="1.5"/>
|
||||||
|
<path d="M47 43h7l2 16h-4l-1-8h-2l-1 8h-4z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 480 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80" role="img" aria-label="Subgun silhouette">
|
||||||
|
<rect width="120" height="80" rx="16" fill="#4d3f67"/>
|
||||||
|
<path fill="#fff8ef" d="M17 37h45l8-8h16v5l-7 4H68l-5 5H49l-4 13h-6l3-13H29l-7-4H17z"/>
|
||||||
|
<rect x="49" y="23" width="14" height="7" rx="1.5" fill="#fff8ef"/>
|
||||||
|
<path fill="#fff8ef" d="M40 43h8l2 14h-6z"/>
|
||||||
|
<path d="M30 30c3-6 7-8 12-8" stroke="#fff8ef" stroke-width="3" stroke-linecap="round" fill="none"/>
|
||||||
|
<path fill="#ead9bf" d="M48 31h8v10h-8z" opacity=".35"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 537 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80" role="img" aria-label="Pump shotgun silhouette">
|
||||||
|
<rect width="120" height="80" rx="16" fill="#35576b"/>
|
||||||
|
<path fill="#fff8ef" d="M10 37h76l11-4h11v3l-9 3H88l-9 6H61l-8 15h-6l4-15H10z"/>
|
||||||
|
<rect x="54" y="24" width="16" height="5" rx="1.5" fill="#fff8ef"/>
|
||||||
|
<rect x="72" y="33" width="18" height="2.5" rx="1.25" fill="#fff8ef"/>
|
||||||
|
<path fill="#ead9bf" d="M49 45h8l-2 9h-3z" opacity=".35"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,430 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0d1216;
|
||||||
|
--panel: rgba(20, 28, 34, 0.92);
|
||||||
|
--panel-soft: rgba(28, 38, 45, 0.84);
|
||||||
|
--line: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: #edf3ef;
|
||||||
|
--muted: #97a8a5;
|
||||||
|
--gold: #d8b36a;
|
||||||
|
--accent: #78b8a4;
|
||||||
|
--shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(216, 179, 106, 0.16), transparent 22%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(120, 184, 164, 0.14), transparent 24%),
|
||||||
|
linear-gradient(180deg, #11181d 0%, #0a0f12 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
.eyebrow,
|
||||||
|
.panel-kicker {
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.03)),
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5L10 12.5L15 7.5' stroke='%23d8b36a' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
|
||||||
|
no-repeat right 14px center;
|
||||||
|
padding-right: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
color: var(--text);
|
||||||
|
background: #162027;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 290px minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
width: min(1440px, calc(100% - 28px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 0 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.panel,
|
||||||
|
.summary-card,
|
||||||
|
.error-banner {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 28px;
|
||||||
|
position: sticky;
|
||||||
|
top: 20px;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block h1,
|
||||||
|
.stage-header h2 {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block p,
|
||||||
|
.summary-card p,
|
||||||
|
.placeholder-copy,
|
||||||
|
.settings-row p,
|
||||||
|
.ammo-card p,
|
||||||
|
.firearm-card p,
|
||||||
|
.mini-stat span,
|
||||||
|
.card-footer span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.panel-kicker {
|
||||||
|
color: var(--gold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button,
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.chip-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button.active {
|
||||||
|
background: linear-gradient(135deg, rgba(216, 179, 106, 0.18), rgba(120, 184, 164, 0.12));
|
||||||
|
border-color: rgba(216, 179, 106, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(180deg, rgba(216, 179, 106, 0.12), rgba(255, 255, 255, 0.02));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-stage {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading,
|
||||||
|
.card-footer,
|
||||||
|
.ammo-card-top,
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-grid,
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(340px, 0.8fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.firearm-grid,
|
||||||
|
.ammo-grid,
|
||||||
|
.settings-list,
|
||||||
|
.chip-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ammo-toolbar {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.firearm-card,
|
||||||
|
.ammo-card {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.firearm-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.firearm-visual {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px;
|
||||||
|
aspect-ratio: 16 / 7;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.firearm-visual img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.firearm-photo {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.firearm-silhouette {
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 10px;
|
||||||
|
filter: brightness(0) invert(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid.compact {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.chip-button {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #16120d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-button {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-button.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-inline input {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(120, 184, 164, 0.14);
|
||||||
|
border: 1px solid rgba(120, 184, 164, 0.18);
|
||||||
|
color: #c9efe4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(201, 83, 83, 0.16);
|
||||||
|
border-color: rgba(201, 83, 83, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.app-shell,
|
||||||
|
.view-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app-shell {
|
||||||
|
width: min(100% - 16px, 1440px);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-header,
|
||||||
|
.panel-heading,
|
||||||
|
.card-footer,
|
||||||
|
.button-row,
|
||||||
|
.settings-inline,
|
||||||
|
.settings-row,
|
||||||
|
.ammo-card-top {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-stats,
|
||||||
|
.form-grid,
|
||||||
|
.form-grid.compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,879 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Boxes, Settings, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
type Firearm = {
|
||||||
|
id: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
category: string;
|
||||||
|
caliber: string;
|
||||||
|
serialNumber: string;
|
||||||
|
purchasePrice: number;
|
||||||
|
acquiredOn: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Caliber = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AmmoInventory = {
|
||||||
|
caliberId: string;
|
||||||
|
caliber: string;
|
||||||
|
roundsOnHand: number;
|
||||||
|
costPerRound: number;
|
||||||
|
totalValue: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardData = {
|
||||||
|
summary: {
|
||||||
|
totalFirearms: number;
|
||||||
|
totalAmmoRounds: number;
|
||||||
|
firearmsInvestment: number;
|
||||||
|
ammoInvestment: number;
|
||||||
|
configuredCalibers: number;
|
||||||
|
};
|
||||||
|
firearms: Firearm[];
|
||||||
|
calibers: Caliber[];
|
||||||
|
ammoInventory: AmmoInventory[];
|
||||||
|
defaultCalibers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FirearmForm = {
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
category: string;
|
||||||
|
caliber: string;
|
||||||
|
serialNumber: string;
|
||||||
|
purchasePrice: string;
|
||||||
|
acquiredOn: string;
|
||||||
|
imageUrl: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AmmoAdjustments = Record<string, { rounds: string; costPerRound: string }>;
|
||||||
|
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
||||||
|
const storageKey = 'arsenal-iq-dashboard';
|
||||||
|
|
||||||
|
const currency = new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other'];
|
||||||
|
const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP'];
|
||||||
|
|
||||||
|
const fallbackAmmo: AmmoInventory[] = defaultCalibers.map((name) => ({
|
||||||
|
caliberId: `cal-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
||||||
|
caliber: name,
|
||||||
|
roundsOnHand: 0,
|
||||||
|
costPerRound: 0,
|
||||||
|
totalValue: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fallbackCalibers: Caliber[] = defaultCalibers.map((name) => ({
|
||||||
|
id: `cal-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
||||||
|
name,
|
||||||
|
isDefault: true,
|
||||||
|
isActive: fallbackAmmo.some((item) => item.caliber === name) || name === '.308 Win' || name === '.45 ACP',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const computeSummary = (firearms: Firearm[], calibers: Caliber[], ammoInventory: AmmoInventory[]) => {
|
||||||
|
const activeCaliberIds = new Set(
|
||||||
|
calibers.filter((item) => item.isActive).map((item) => item.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeAmmoInventory = ammoInventory.filter((item) => activeCaliberIds.has(item.caliberId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalFirearms: firearms.length,
|
||||||
|
totalAmmoRounds: activeAmmoInventory.reduce((sum, item) => sum + item.roundsOnHand, 0),
|
||||||
|
firearmsInvestment: firearms.reduce((sum, item) => sum + item.purchasePrice, 0),
|
||||||
|
ammoInvestment: activeAmmoInventory.reduce((sum, item) => sum + item.totalValue, 0),
|
||||||
|
configuredCalibers: calibers.filter((item) => item.isActive).length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDashboardData = (
|
||||||
|
firearms: Firearm[] = [],
|
||||||
|
calibers: Caliber[] = fallbackCalibers,
|
||||||
|
ammoInventory: AmmoInventory[] = fallbackAmmo,
|
||||||
|
): DashboardData => ({
|
||||||
|
summary: computeSummary(firearms, calibers, ammoInventory),
|
||||||
|
firearms,
|
||||||
|
calibers,
|
||||||
|
ammoInventory,
|
||||||
|
defaultCalibers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadStoredDashboard = (): DashboardData | null => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePayload(JSON.parse(raw) as DashboardData);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyFirearmForm: FirearmForm = {
|
||||||
|
manufacturer: '',
|
||||||
|
model: '',
|
||||||
|
category: 'Handgun',
|
||||||
|
caliber: '',
|
||||||
|
serialNumber: '',
|
||||||
|
purchasePrice: '',
|
||||||
|
acquiredOn: '',
|
||||||
|
imageUrl: '',
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFirearmForm = (firearm?: Firearm): FirearmForm => ({
|
||||||
|
manufacturer: firearm?.manufacturer ?? '',
|
||||||
|
model: firearm?.model ?? '',
|
||||||
|
category: firearm?.category ?? 'Handgun',
|
||||||
|
caliber: firearm?.caliber ?? '',
|
||||||
|
serialNumber: firearm?.serialNumber ?? '',
|
||||||
|
purchasePrice: firearm ? String(firearm.purchasePrice) : '',
|
||||||
|
acquiredOn: firearm?.acquiredOn ?? '',
|
||||||
|
imageUrl: firearm?.imageUrl ?? '',
|
||||||
|
notes: firearm?.notes ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildAmmoAdjustments = (inventory: AmmoInventory[]): AmmoAdjustments =>
|
||||||
|
Object.fromEntries(
|
||||||
|
inventory.map((item) => [
|
||||||
|
item.caliberId,
|
||||||
|
{
|
||||||
|
rounds: '',
|
||||||
|
costPerRound: item.costPerRound.toFixed(2),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizePayload = (payload: DashboardData): DashboardData =>
|
||||||
|
buildDashboardData(payload.firearms, payload.calibers, payload.ammoInventory);
|
||||||
|
|
||||||
|
const isLocalId = (id: string) => id.startsWith('local-');
|
||||||
|
|
||||||
|
const getCategorySilhouette = (category: string) => {
|
||||||
|
const normalized = category.toLowerCase();
|
||||||
|
|
||||||
|
const silhouettes: Record<string, string> = {
|
||||||
|
handgun: 'https://commons.wikimedia.org/wiki/Special:FilePath/Glock%2019%20silhouette.svg',
|
||||||
|
rifle: 'https://commons.wikimedia.org/wiki/Special:FilePath/Colt%20M4A1%20silhouette.svg',
|
||||||
|
shotgun: 'https://commons.wikimedia.org/wiki/Special:FilePath/Shotgun.svg',
|
||||||
|
pcc: 'https://commons.wikimedia.org/wiki/Special:FilePath/H%26K%20MP5%20silhouette.svg',
|
||||||
|
other: 'https://commons.wikimedia.org/wiki/Special:FilePath/War%20Cannon.svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return silhouettes[normalized] ?? silhouettes.other;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [activeView, setActiveView] = useState<'firearms' | 'ammo' | 'settings'>('firearms');
|
||||||
|
const [data, setData] = useState<DashboardData>(() => loadStoredDashboard() ?? buildDashboardData());
|
||||||
|
const [firearmDrafts, setFirearmDrafts] = useState<Record<string, FirearmForm>>({});
|
||||||
|
const [newFirearm, setNewFirearm] = useState<FirearmForm>(emptyFirearmForm);
|
||||||
|
const [ammoAdjustments, setAmmoAdjustments] = useState<AmmoAdjustments>(() =>
|
||||||
|
buildAmmoAdjustments((loadStoredDashboard() ?? buildDashboardData()).ammoInventory),
|
||||||
|
);
|
||||||
|
const [newCaliber, setNewCaliber] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const activeAmmoInventory = data.ammoInventory.filter((inventory) =>
|
||||||
|
data.calibers.some((caliber) => caliber.id === inventory.caliberId && caliber.isActive),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ammoTypesWithRounds = activeAmmoInventory.filter(
|
||||||
|
(inventory) => inventory.roundsOnHand > 0,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const applyDashboard = (payload: DashboardData) => {
|
||||||
|
const normalized = normalizePayload(payload);
|
||||||
|
setData(normalized);
|
||||||
|
setFirearmDrafts(
|
||||||
|
Object.fromEntries(normalized.firearms.map((firearm) => [firearm.id, buildFirearmForm(firearm)])),
|
||||||
|
);
|
||||||
|
setAmmoAdjustments(buildAmmoAdjustments(normalized.ammoInventory));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(data));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const refreshDashboard = async () => {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/dashboard`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Dashboard request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as DashboardData;
|
||||||
|
applyDashboard(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
await refreshDashboard();
|
||||||
|
} catch {
|
||||||
|
// Keep local fallback ammo/caliber data while API is restarting.
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFirearmChange = (id: string, field: keyof FirearmForm, value: string) => {
|
||||||
|
setFirearmDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[id]: {
|
||||||
|
...current[id],
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFirearm = async (id: string) => {
|
||||||
|
const draft = firearmDrafts[id];
|
||||||
|
const updatedFirearm: Firearm = {
|
||||||
|
id,
|
||||||
|
manufacturer: draft.manufacturer,
|
||||||
|
model: draft.model,
|
||||||
|
category: draft.category,
|
||||||
|
caliber: draft.caliber,
|
||||||
|
serialNumber: draft.serialNumber,
|
||||||
|
purchasePrice: Number(draft.purchasePrice || 0),
|
||||||
|
acquiredOn: draft.acquiredOn || null,
|
||||||
|
imageUrl: draft.imageUrl || null,
|
||||||
|
notes: draft.notes || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setData((current) =>
|
||||||
|
buildDashboardData(
|
||||||
|
current.firearms.map((firearm) => (firearm.id === id ? updatedFirearm : firearm)),
|
||||||
|
current.calibers,
|
||||||
|
current.ammoInventory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = isLocalId(id) ? `${apiBaseUrl}/firearms` : `${apiBaseUrl}/firearms/${id}`;
|
||||||
|
const method = isLocalId(id) ? 'POST' : 'PUT';
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...draft,
|
||||||
|
purchasePrice: Number(draft.purchasePrice),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (isLocalId(id)) {
|
||||||
|
const savedFirearm = (await response.json()) as Firearm;
|
||||||
|
setData((current) =>
|
||||||
|
buildDashboardData(
|
||||||
|
current.firearms.map((firearm) => (firearm.id === id ? savedFirearm : firearm)),
|
||||||
|
current.calibers,
|
||||||
|
current.ammoInventory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setFirearmDrafts((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[id];
|
||||||
|
next[savedFirearm.id] = buildFirearmForm(savedFirearm);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await refreshDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep optimistic update.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFirearm = async () => {
|
||||||
|
const draft = { ...newFirearm };
|
||||||
|
const createdFirearm: Firearm = {
|
||||||
|
id: `local-${Date.now()}`,
|
||||||
|
manufacturer: draft.manufacturer,
|
||||||
|
model: draft.model,
|
||||||
|
category: draft.category,
|
||||||
|
caliber: draft.caliber,
|
||||||
|
serialNumber: draft.serialNumber,
|
||||||
|
purchasePrice: Number(draft.purchasePrice || 0),
|
||||||
|
acquiredOn: draft.acquiredOn || null,
|
||||||
|
imageUrl: draft.imageUrl || null,
|
||||||
|
notes: draft.notes || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setData((current) =>
|
||||||
|
buildDashboardData([...current.firearms, createdFirearm], current.calibers, current.ammoInventory),
|
||||||
|
);
|
||||||
|
setFirearmDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[createdFirearm.id]: buildFirearmForm(createdFirearm),
|
||||||
|
}));
|
||||||
|
setNewFirearm(emptyFirearmForm);
|
||||||
|
setActiveView('firearms');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/firearms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...draft,
|
||||||
|
purchasePrice: Number(draft.purchasePrice),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const savedFirearm = (await response.json()) as Firearm;
|
||||||
|
setData((current) =>
|
||||||
|
buildDashboardData(
|
||||||
|
current.firearms.map((firearm) => (firearm.id === createdFirearm.id ? savedFirearm : firearm)),
|
||||||
|
current.calibers,
|
||||||
|
current.ammoInventory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setFirearmDrafts((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[createdFirearm.id];
|
||||||
|
next[savedFirearm.id] = buildFirearmForm(savedFirearm);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep optimistic row visible.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFirearm = async (id: string) => {
|
||||||
|
setData((current) =>
|
||||||
|
buildDashboardData(
|
||||||
|
current.firearms.filter((firearm) => firearm.id !== id),
|
||||||
|
current.calibers,
|
||||||
|
current.ammoInventory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setFirearmDrafts((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLocalId(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/firearms/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok || response.status === 204) {
|
||||||
|
await refreshDashboard();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep optimistic delete.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const adjustAmmo = async (caliberId: string) => {
|
||||||
|
const adjustment = ammoAdjustments[caliberId];
|
||||||
|
const roundsDelta = Number(adjustment?.rounds || 0);
|
||||||
|
const nextCost = Number(adjustment?.costPerRound || 0);
|
||||||
|
|
||||||
|
setData((current) => {
|
||||||
|
const nextInventory = current.ammoInventory.map((item) => {
|
||||||
|
if (item.caliberId !== caliberId) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundsOnHand = Math.max(0, item.roundsOnHand + roundsDelta);
|
||||||
|
const costPerRound = nextCost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
roundsOnHand,
|
||||||
|
costPerRound,
|
||||||
|
totalValue: roundsOnHand * costPerRound,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildDashboardData(current.firearms, current.calibers, nextInventory);
|
||||||
|
});
|
||||||
|
|
||||||
|
setAmmoAdjustments((current) => ({
|
||||||
|
...current,
|
||||||
|
[caliberId]: {
|
||||||
|
...current[caliberId],
|
||||||
|
rounds: '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/ammo/${caliberId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
rounds: roundsDelta,
|
||||||
|
costPerRound: nextCost,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await refreshDashboard();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep optimistic update.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCaliber = async (name: string) => {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
|
||||||
|
if (!trimmedName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = data.calibers.find((item) => item.name.toLowerCase() === trimmedName.toLowerCase());
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const caliberId = `local-${trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||||
|
const nextCaliber: Caliber = {
|
||||||
|
id: caliberId,
|
||||||
|
name: trimmedName,
|
||||||
|
isDefault: defaultCalibers.includes(trimmedName),
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
const nextInventory: AmmoInventory = {
|
||||||
|
caliberId,
|
||||||
|
caliber: trimmedName,
|
||||||
|
roundsOnHand: 0,
|
||||||
|
costPerRound: 0,
|
||||||
|
totalValue: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
setData((current) =>
|
||||||
|
buildDashboardData(
|
||||||
|
current.firearms,
|
||||||
|
[...current.calibers, nextCaliber],
|
||||||
|
[...current.ammoInventory, nextInventory],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setAmmoAdjustments((current) => ({
|
||||||
|
...current,
|
||||||
|
[caliberId]: { rounds: '', costPerRound: '0.00' },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setData((current) =>
|
||||||
|
buildDashboardData(
|
||||||
|
current.firearms,
|
||||||
|
current.calibers.map((item) =>
|
||||||
|
item.id === existing.id ? { ...item, isActive: true } : item,
|
||||||
|
),
|
||||||
|
current.ammoInventory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewCaliber('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/calibers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: trimmedName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await refreshDashboard();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep optimistic update.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCaliber = async (caliberId: string, isActive: boolean) => {
|
||||||
|
setData((current) =>
|
||||||
|
buildDashboardData(
|
||||||
|
current.firearms,
|
||||||
|
current.calibers.map((item) => (item.id === caliberId ? { ...item, isActive } : item)),
|
||||||
|
current.ammoInventory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/calibers/${caliberId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ isActive }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await refreshDashboard();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep optimistic update.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{ id: 'firearms', label: 'Firearms', icon: ShieldCheck },
|
||||||
|
{ id: 'ammo', label: 'Ammo', icon: Boxes },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand-block">
|
||||||
|
<span className="eyebrow">Arsenal IQ</span>
|
||||||
|
<h1>Inventory Control</h1>
|
||||||
|
<p>Track firearms, calibers, and ammo counts cleanly.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="nav-stack">
|
||||||
|
{views.map((view) => {
|
||||||
|
const Icon = view.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={activeView === view.id ? 'nav-button active' : 'nav-button'}
|
||||||
|
key={view.id}
|
||||||
|
onClick={() => setActiveView(view.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{view.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="main-stage">
|
||||||
|
<header className="stage-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Overview</span>
|
||||||
|
<h2>
|
||||||
|
{activeView === 'firearms'
|
||||||
|
? 'Firearms'
|
||||||
|
: activeView === 'ammo'
|
||||||
|
? 'Ammo'
|
||||||
|
: 'Settings'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-stats">
|
||||||
|
<div className="mini-stat">
|
||||||
|
<span>{activeView === 'ammo' ? 'Ammo types' : 'Firearms'}</span>
|
||||||
|
<strong>
|
||||||
|
{activeView === 'ammo'
|
||||||
|
? ammoTypesWithRounds
|
||||||
|
: data.summary.totalFirearms}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="mini-stat">
|
||||||
|
<span>{activeView === 'ammo' ? 'Total rounds' : 'Total firearm value'}</span>
|
||||||
|
<strong>
|
||||||
|
{activeView === 'ammo'
|
||||||
|
? data.summary.totalAmmoRounds
|
||||||
|
: currency.format(data.summary.firearmsInvestment)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{activeView === 'firearms' ? (
|
||||||
|
<section className="view-grid">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="panel-kicker">Registry</span>
|
||||||
|
<h3>Existing firearms</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="firearm-grid">
|
||||||
|
{data.firearms.length === 0 ? (
|
||||||
|
<p className="placeholder-copy">No firearms yet. Add your first record from the panel on the right.</p>
|
||||||
|
) : (
|
||||||
|
data.firearms.map((firearm) => {
|
||||||
|
const draft = firearmDrafts[firearm.id] ?? buildFirearmForm(firearm);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="firearm-card" key={firearm.id}>
|
||||||
|
<div className="firearm-visual">
|
||||||
|
<img
|
||||||
|
className={draft.imageUrl ? 'firearm-photo' : 'firearm-silhouette'}
|
||||||
|
alt={`${firearm.manufacturer} ${firearm.model}`}
|
||||||
|
src={draft.imageUrl || getCategorySilhouette(draft.category)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Manufacturer</span>
|
||||||
|
<input value={draft.manufacturer} onChange={(event) => handleFirearmChange(firearm.id, 'manufacturer', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Model</span>
|
||||||
|
<input value={draft.model} onChange={(event) => handleFirearmChange(firearm.id, 'model', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Category</span>
|
||||||
|
<select value={draft.category} onChange={(event) => handleFirearmChange(firearm.id, 'category', event.target.value)}>
|
||||||
|
{firearmCategories.map((category) => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Caliber</span>
|
||||||
|
<select value={draft.caliber} onChange={(event) => handleFirearmChange(firearm.id, 'caliber', event.target.value)}>
|
||||||
|
<option value="">Select caliber</option>
|
||||||
|
{data.calibers
|
||||||
|
.filter((caliber) => caliber.isActive)
|
||||||
|
.map((caliber) => (
|
||||||
|
<option key={caliber.id} value={caliber.name}>
|
||||||
|
{caliber.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Serial number</span>
|
||||||
|
<input value={draft.serialNumber} onChange={(event) => handleFirearmChange(firearm.id, 'serialNumber', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Purchase cost</span>
|
||||||
|
<input type="number" step="0.01" value={draft.purchasePrice} onChange={(event) => handleFirearmChange(firearm.id, 'purchasePrice', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Purchase date</span>
|
||||||
|
<input type="date" value={draft.acquiredOn} onChange={(event) => handleFirearmChange(firearm.id, 'acquiredOn', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Image URL</span>
|
||||||
|
<input value={draft.imageUrl} onChange={(event) => handleFirearmChange(firearm.id, 'imageUrl', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="full-width">
|
||||||
|
<span>Notes</span>
|
||||||
|
<textarea rows={3} value={draft.notes} onChange={(event) => handleFirearmChange(firearm.id, 'notes', event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<span>{draft.acquiredOn ? `Purchased ${draft.acquiredOn}` : 'Purchase date untracked'}</span>
|
||||||
|
<div className="button-row">
|
||||||
|
<button className="secondary-button" onClick={() => void deleteFirearm(firearm.id)} type="button">
|
||||||
|
Remove firearm
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" onClick={() => void saveFirearm(firearm.id)} type="button">
|
||||||
|
Save firearm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="panel-kicker">Add Record</span>
|
||||||
|
<h3>New firearm</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Manufacturer</span>
|
||||||
|
<input value={newFirearm.manufacturer} onChange={(event) => setNewFirearm({ ...newFirearm, manufacturer: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Model</span>
|
||||||
|
<input value={newFirearm.model} onChange={(event) => setNewFirearm({ ...newFirearm, model: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Category</span>
|
||||||
|
<select value={newFirearm.category} onChange={(event) => setNewFirearm({ ...newFirearm, category: event.target.value })}>
|
||||||
|
{firearmCategories.map((category) => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Caliber</span>
|
||||||
|
<select value={newFirearm.caliber} onChange={(event) => setNewFirearm({ ...newFirearm, caliber: event.target.value })}>
|
||||||
|
<option value="">Select caliber</option>
|
||||||
|
{data.calibers
|
||||||
|
.filter((caliber) => caliber.isActive)
|
||||||
|
.map((caliber) => (
|
||||||
|
<option key={caliber.id} value={caliber.name}>
|
||||||
|
{caliber.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Serial number</span>
|
||||||
|
<input value={newFirearm.serialNumber} onChange={(event) => setNewFirearm({ ...newFirearm, serialNumber: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Purchase cost</span>
|
||||||
|
<input type="number" step="0.01" value={newFirearm.purchasePrice} onChange={(event) => setNewFirearm({ ...newFirearm, purchasePrice: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Purchase date</span>
|
||||||
|
<input type="date" value={newFirearm.acquiredOn} onChange={(event) => setNewFirearm({ ...newFirearm, acquiredOn: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Image URL</span>
|
||||||
|
<input value={newFirearm.imageUrl} onChange={(event) => setNewFirearm({ ...newFirearm, imageUrl: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="full-width">
|
||||||
|
<span>Notes</span>
|
||||||
|
<textarea rows={3} value={newFirearm.notes} onChange={(event) => setNewFirearm({ ...newFirearm, notes: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-footer">
|
||||||
|
<button className="primary-button" onClick={() => void createFirearm()} type="button">
|
||||||
|
Add firearm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeView === 'ammo' ? (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="panel-kicker">Ammo</span>
|
||||||
|
<h3>Configured calibers and round counts</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ammo-grid">
|
||||||
|
{activeAmmoInventory.map((inventory) => (
|
||||||
|
<article className="ammo-card" key={inventory.caliberId}>
|
||||||
|
<div className="ammo-card-top">
|
||||||
|
<div>
|
||||||
|
<strong>{inventory.caliber}</strong>
|
||||||
|
<p>{inventory.roundsOnHand} rounds on hand</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid compact">
|
||||||
|
<label>
|
||||||
|
<span>Add or remove rounds</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ammoAdjustments[inventory.caliberId]?.rounds ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAmmoAdjustments((current) => ({
|
||||||
|
...current,
|
||||||
|
[inventory.caliberId]: {
|
||||||
|
...current[inventory.caliberId],
|
||||||
|
rounds: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-footer">
|
||||||
|
<span>Positive numbers add rounds. Negative numbers remove them.</span>
|
||||||
|
<button className="primary-button" onClick={() => void adjustAmmo(inventory.caliberId)} type="button">
|
||||||
|
Update count
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeView === 'settings' ? (
|
||||||
|
<section className="settings-grid">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="panel-kicker">Defaults</span>
|
||||||
|
<h3>Enable common calibers</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chip-grid">
|
||||||
|
{data.defaultCalibers.map((caliber) => {
|
||||||
|
const configured = data.calibers.some((item) => item.name === caliber && item.isActive);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={configured ? 'chip-button disabled' : 'chip-button'}
|
||||||
|
disabled={configured}
|
||||||
|
key={caliber}
|
||||||
|
onClick={() => void addCaliber(caliber)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{configured ? `${caliber} enabled` : `Enable ${caliber}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="panel-kicker">Custom</span>
|
||||||
|
<h3>Add a caliber</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-inline">
|
||||||
|
<input placeholder="6.5 Creedmoor" value={newCaliber} onChange={(event) => setNewCaliber(event.target.value)} />
|
||||||
|
<button className="primary-button" onClick={() => void addCaliber(newCaliber)} type="button">
|
||||||
|
Add caliber
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
target: 'http://backend:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user