This commit is contained in:
xCyanGrizzly
2026-02-18 14:26:36 +01:00
commit 3a5726e82b
167 changed files with 104081 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
{
"permissions": {
"allow": [
"WebFetch(domain:blog.disane.dev)",
"WebFetch(domain:github.com)",
"Bash(npx create-next-app:*)",
"Bash(npm install:*)",
"Bash(npx prisma init:*)",
"Bash(npx shadcn@latest init:*)",
"Bash(npx shadcn@latest add button card dialog dropdown-menu input label select table badge separator skeleton tooltip alert-dialog checkbox switch textarea avatar popover command form scroll-area sheet tabs sonner)",
"Bash(npx prisma generate)",
"Bash(npx tsc:*)",
"Bash(node -e:*)",
"Bash(npm ls:*)",
"Bash(npm run build:*)",
"Bash(docker:*)",
"Bash(docker.exe --version:*)",
"Bash(where:*)",
"Bash(cmd.exe /c \"where docker\")",
"Bash(cmd.exe /c \"where psql\")",
"Bash(cmd.exe /c \"where pg_isready\")",
"Bash(cmd.exe:*)",
"Bash(powershell.exe -Command \"winget --version\")",
"Bash(powershell.exe:*)",
"Bash(npx prisma migrate:*)",
"Bash(npx prisma db seed:*)",
"Bash(npm run dev:*)",
"WebFetch(domain:acrylicosvallejo.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:siraya.tech)",
"WebFetch(domain:us.elegoo.com)",
"WebFetch(domain:thearmypainter.com)",
"Bash(ls:*)",
"Bash(npx tsx:*)",
"Bash(export PATH=\"$PATH:/c/Program Files/Docker/Docker/resources/bin\")",
"Bash(curl:*)",
"Bash(node scripts/e2e-test.mjs:*)",
"Bash(tasklist:*)",
"Bash(python3:*)",
"Bash(for page in filaments resins paints dashboard)",
"Bash(do echo -n \"$page: \")",
"Bash(done)"
]
}
}

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.next
.git
.gitignore
*.md
docker-compose*.yml
.env*
!.env.example
.vscode
.idea

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Database
DATABASE_URL="postgresql://dragons:stash@localhost:5432/dragonsstash?schema=public"
# Auth.js
AUTH_SECRET="generate-with-openssl-rand-base64-32"
AUTH_TRUST_HOST=true
# GitHub OAuth (optional)
AUTH_GITHUB_ID=""
AUTH_GITHUB_SECRET=""
# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"

58
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: dragons
POSTGRES_PASSWORD: stash
POSTGRES_DB: dragonsstash
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U dragons -d dragonsstash"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://dragons:stash@localhost:5432/dragonsstash
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build
env:
DATABASE_URL: postgresql://dragons:stash@localhost:5432/dragonsstash
AUTH_SECRET: ci-test-secret-not-for-production
AUTH_TRUST_HOST: "true"
NEXT_PUBLIC_APP_URL: http://localhost:3000

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# prisma
prisma/migrations/**/migration_lock.toml
# ide
.idea
.vscode
/src/generated/prisma

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"tabWidth": 2,
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}

60
Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
# ---- Base ----
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
# ---- Dependencies ----
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY prisma ./prisma/
COPY prisma.config.ts ./
RUN npx prisma generate
# ---- Build ----
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# ---- Production ----
FROM base AS runner
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma files for migrations
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma
# Copy entrypoint
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Dragon's Stash Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

155
README.md Normal file
View File

@@ -0,0 +1,155 @@
# Dragon's Stash
A self-hosted inventory management system for 3D printing filament, SLA resin, and miniature paints. Built with a dark, data-dense UI inspired by [Spoolman](https://github.com/Donkie/Spoolman).
## Features
- **Filament tracking** with spool weight, material type, color swatches, and usage logging
- **SLA resin management** with bottle sizes, resin types, and remaining volume tracking
- **Miniature paint inventory** with product lines, finishes, and volume tracking
- **Dashboard** with inventory stats, low-stock alerts, and recent activity
- **Vendor and location management** to organize your supplies
- **Usage logging** to track consumption over time
- **Low-stock alerts** with configurable threshold percentage
- **Dark theme** optimized for workshop environments
- **Role-based auth** with admin and user roles
- **Docker-ready** for easy self-hosting
## Tech Stack
- **Framework**: Next.js 16 (App Router)
- **Language**: TypeScript (strict mode)
- **Database**: PostgreSQL with Prisma ORM
- **Auth**: Auth.js v5 (credentials + GitHub OAuth)
- **UI**: Tailwind CSS, shadcn/ui, Lucide icons
- **Tables**: TanStack Table v8 with server-side pagination
- **Validation**: Zod v4 + React Hook Form
## Quick Start
### Prerequisites
- Node.js 20+
- PostgreSQL 16+ (or Docker)
### Development Setup
1. Clone the repository:
```bash
git clone https://github.com/your-username/dragons-stash.git
cd dragons-stash
```
2. Install dependencies:
```bash
npm install
```
3. Start a PostgreSQL database (using Docker):
```bash
docker compose -f docker-compose.dev.yml up -d
```
4. Copy the environment file and update values:
```bash
cp .env.example .env.local
```
5. Run database migrations and seed:
```bash
npx prisma migrate dev
npx prisma db seed
```
6. Start the development server:
```bash
npm run dev
```
7. Open [http://localhost:3000](http://localhost:3000) and log in:
- **Admin**: admin@dragonsstash.local / password123
- **User**: user@dragonsstash.local / password123
### Docker Deployment
```bash
docker compose up -d
```
This starts both the application and PostgreSQL database. The app will be available at `http://localhost:3000`.
To seed the database on first run:
```bash
SEED_DATABASE=true docker compose up -d
```
## Project Structure
```
src/
app/
(auth)/ # Login/Register pages
(app)/ # Authenticated app pages
dashboard/ # Overview stats
filaments/ # Filament CRUD
resins/ # Resin CRUD
paints/ # Paint CRUD
vendors/ # Vendor management
locations/ # Location management
settings/ # User preferences
api/
auth/ # NextAuth API routes
health/ # Health check endpoint
components/
layout/ # Sidebar, header, navigation
shared/ # Reusable data table components
ui/ # shadcn/ui components
data/ # Prisma query functions
hooks/ # React hooks
lib/ # Auth config, Prisma client, constants
schemas/ # Zod validation schemas
types/ # TypeScript type definitions
prisma/
schema.prisma # Database schema
seed.ts # Seed data
```
## Configuration
Environment variables (see `.env.example`):
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | Required |
| `AUTH_SECRET` | NextAuth secret key | Required |
| `AUTH_TRUST_HOST` | Trust the host header | `true` |
| `AUTH_GITHUB_ID` | GitHub OAuth client ID | Optional |
| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret | Optional |
| `NEXT_PUBLIC_APP_URL` | Public application URL | `http://localhost:3000` |
## Health Check
The application exposes a health check endpoint at `/api/health` that verifies database connectivity.
```bash
curl http://localhost:3000/api/health
```
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

23
components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

19
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: dragons
POSTGRES_PASSWORD: stash
POSTGRES_DB: dragonsstash
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dragons -d dragonsstash"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_dev_data:

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://dragons:stash@db:5432/dragonsstash
- AUTH_SECRET=change-me-to-a-random-secret-in-production
- AUTH_TRUST_HOST=true
- NEXT_PUBLIC_APP_URL=http://localhost:3000
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: dragons
POSTGRES_PASSWORD: stash
POSTGRES_DB: dragonsstash
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dragons -d dragonsstash"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres_data:

24
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -e
echo "Dragon's Stash - Starting..."
# Run database migrations
echo "Running database migrations..."
npx prisma migrate deploy 2>/dev/null || {
echo "WARNING: Migration failed. Database may not be ready yet."
echo "Retrying in 5 seconds..."
sleep 5
npx prisma migrate deploy
}
echo "Migrations complete."
# Optionally seed database
if [ "$SEED_DATABASE" = "true" ]; then
echo "Seeding database..."
npx prisma db seed || echo "Seeding skipped or already done."
fi
echo "Starting application..."
exec "$@"

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

10
middleware.ts Normal file
View File

@@ -0,0 +1,10 @@
import NextAuth from "next-auth";
import authConfig from "@/lib/auth.config";
const { auth } = NextAuth(authConfig);
export default auth;
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|logo.svg|og-image.png).*)"],
};

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

13910
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "dragons-stash",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:studio": "prisma studio"
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.4.0",
"@prisma/client": "^7.4.0",
"@tanstack/react-table": "^8.21.3",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.3.1",
"lucide-react": "^0.574.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"prettier-plugin-tailwindcss": "^0.7.2",
"prisma": "^7.4.0",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

15
prisma.config.ts Normal file
View File

@@ -0,0 +1,15 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "npx tsx prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,385 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"hashedPassword" TEXT,
"role" "Role" NOT NULL DEFAULT 'USER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Vendor" (
"id" TEXT NOT NULL,
"name" VARCHAR(64) NOT NULL,
"website" VARCHAR(256),
"notes" TEXT,
"archived" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Vendor_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"name" VARCHAR(64) NOT NULL,
"description" VARCHAR(256),
"archived" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Filament" (
"id" TEXT NOT NULL,
"name" VARCHAR(128) NOT NULL,
"brand" VARCHAR(64) NOT NULL,
"material" VARCHAR(32) NOT NULL,
"color" VARCHAR(64) NOT NULL,
"colorHex" VARCHAR(7) NOT NULL,
"diameter" DOUBLE PRECISION NOT NULL DEFAULT 1.75,
"spoolWeight" DOUBLE PRECISION NOT NULL,
"usedWeight" DOUBLE PRECISION NOT NULL DEFAULT 0,
"emptySpoolWeight" DOUBLE PRECISION NOT NULL DEFAULT 0,
"purchaseDate" TIMESTAMP(3),
"cost" DOUBLE PRECISION,
"notes" TEXT,
"archived" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"vendorId" TEXT,
"locationId" TEXT,
CONSTRAINT "Filament_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Resin" (
"id" TEXT NOT NULL,
"name" VARCHAR(128) NOT NULL,
"brand" VARCHAR(64) NOT NULL,
"resinType" VARCHAR(32) NOT NULL,
"color" VARCHAR(64) NOT NULL,
"colorHex" VARCHAR(7) NOT NULL,
"bottleSize" DOUBLE PRECISION NOT NULL,
"usedML" DOUBLE PRECISION NOT NULL DEFAULT 0,
"purchaseDate" TIMESTAMP(3),
"cost" DOUBLE PRECISION,
"notes" TEXT,
"archived" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"vendorId" TEXT,
"locationId" TEXT,
CONSTRAINT "Resin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Paint" (
"id" TEXT NOT NULL,
"name" VARCHAR(128) NOT NULL,
"brand" VARCHAR(64) NOT NULL,
"line" VARCHAR(64),
"color" VARCHAR(64) NOT NULL,
"colorHex" VARCHAR(7) NOT NULL,
"finish" VARCHAR(32) NOT NULL,
"volumeML" DOUBLE PRECISION NOT NULL,
"usedML" DOUBLE PRECISION NOT NULL DEFAULT 0,
"purchaseDate" TIMESTAMP(3),
"cost" DOUBLE PRECISION,
"notes" TEXT,
"archived" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"vendorId" TEXT,
"locationId" TEXT,
CONSTRAINT "Paint_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" VARCHAR(64) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TagOnFilament" (
"filamentId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "TagOnFilament_pkey" PRIMARY KEY ("filamentId","tagId")
);
-- CreateTable
CREATE TABLE "TagOnResin" (
"resinId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "TagOnResin_pkey" PRIMARY KEY ("resinId","tagId")
);
-- CreateTable
CREATE TABLE "TagOnPaint" (
"paintId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "TagOnPaint_pkey" PRIMARY KEY ("paintId","tagId")
);
-- CreateTable
CREATE TABLE "UsageLog" (
"id" TEXT NOT NULL,
"itemType" VARCHAR(16) NOT NULL,
"itemId" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"unit" VARCHAR(4) NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"filamentId" TEXT,
"resinId" TEXT,
"paintId" TEXT,
CONSTRAINT "UsageLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserSettings" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"lowStockThreshold" DOUBLE PRECISION NOT NULL DEFAULT 10,
"currency" VARCHAR(3) NOT NULL DEFAULT 'USD',
"theme" VARCHAR(8) NOT NULL DEFAULT 'dark',
"units" VARCHAR(8) NOT NULL DEFAULT 'metric',
CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE INDEX "Vendor_userId_idx" ON "Vendor"("userId");
-- CreateIndex
CREATE INDEX "Vendor_archived_idx" ON "Vendor"("archived");
-- CreateIndex
CREATE INDEX "Location_userId_idx" ON "Location"("userId");
-- CreateIndex
CREATE INDEX "Location_archived_idx" ON "Location"("archived");
-- CreateIndex
CREATE INDEX "Filament_userId_idx" ON "Filament"("userId");
-- CreateIndex
CREATE INDEX "Filament_vendorId_idx" ON "Filament"("vendorId");
-- CreateIndex
CREATE INDEX "Filament_locationId_idx" ON "Filament"("locationId");
-- CreateIndex
CREATE INDEX "Filament_material_idx" ON "Filament"("material");
-- CreateIndex
CREATE INDEX "Filament_archived_idx" ON "Filament"("archived");
-- CreateIndex
CREATE INDEX "Filament_brand_idx" ON "Filament"("brand");
-- CreateIndex
CREATE INDEX "Resin_userId_idx" ON "Resin"("userId");
-- CreateIndex
CREATE INDEX "Resin_vendorId_idx" ON "Resin"("vendorId");
-- CreateIndex
CREATE INDEX "Resin_locationId_idx" ON "Resin"("locationId");
-- CreateIndex
CREATE INDEX "Resin_resinType_idx" ON "Resin"("resinType");
-- CreateIndex
CREATE INDEX "Resin_archived_idx" ON "Resin"("archived");
-- CreateIndex
CREATE INDEX "Resin_brand_idx" ON "Resin"("brand");
-- CreateIndex
CREATE INDEX "Paint_userId_idx" ON "Paint"("userId");
-- CreateIndex
CREATE INDEX "Paint_vendorId_idx" ON "Paint"("vendorId");
-- CreateIndex
CREATE INDEX "Paint_locationId_idx" ON "Paint"("locationId");
-- CreateIndex
CREATE INDEX "Paint_finish_idx" ON "Paint"("finish");
-- CreateIndex
CREATE INDEX "Paint_archived_idx" ON "Paint"("archived");
-- CreateIndex
CREATE INDEX "Paint_brand_idx" ON "Paint"("brand");
-- CreateIndex
CREATE INDEX "Tag_userId_idx" ON "Tag"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_userId_key" ON "Tag"("name", "userId");
-- CreateIndex
CREATE INDEX "UsageLog_userId_idx" ON "UsageLog"("userId");
-- CreateIndex
CREATE INDEX "UsageLog_itemType_itemId_idx" ON "UsageLog"("itemType", "itemId");
-- CreateIndex
CREATE INDEX "UsageLog_createdAt_idx" ON "UsageLog"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "UserSettings_userId_key" ON "UserSettings"("userId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vendor" ADD CONSTRAINT "Vendor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Location" ADD CONSTRAINT "Location_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Filament" ADD CONSTRAINT "Filament_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Filament" ADD CONSTRAINT "Filament_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Filament" ADD CONSTRAINT "Filament_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Resin" ADD CONSTRAINT "Resin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Resin" ADD CONSTRAINT "Resin_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Resin" ADD CONSTRAINT "Resin_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Paint" ADD CONSTRAINT "Paint_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Paint" ADD CONSTRAINT "Paint_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Paint" ADD CONSTRAINT "Paint_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagOnFilament" ADD CONSTRAINT "TagOnFilament_filamentId_fkey" FOREIGN KEY ("filamentId") REFERENCES "Filament"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagOnFilament" ADD CONSTRAINT "TagOnFilament_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagOnResin" ADD CONSTRAINT "TagOnResin_resinId_fkey" FOREIGN KEY ("resinId") REFERENCES "Resin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagOnResin" ADD CONSTRAINT "TagOnResin_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagOnPaint" ADD CONSTRAINT "TagOnPaint_paintId_fkey" FOREIGN KEY ("paintId") REFERENCES "Paint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagOnPaint" ADD CONSTRAINT "TagOnPaint_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UsageLog" ADD CONSTRAINT "UsageLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UsageLog" ADD CONSTRAINT "UsageLog_filamentId_fkey" FOREIGN KEY ("filamentId") REFERENCES "Filament"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UsageLog" ADD CONSTRAINT "UsageLog_resinId_fkey" FOREIGN KEY ("resinId") REFERENCES "Resin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UsageLog" ADD CONSTRAINT "UsageLog_paintId_fkey" FOREIGN KEY ("paintId") REFERENCES "Paint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

301
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,301 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// ───────────────────────────────────────
// Auth.js required models
// ───────────────────────────────────────
enum Role {
ADMIN
USER
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
hashedPassword String?
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
filaments Filament[]
resins Resin[]
paints Paint[]
vendors Vendor[]
locations Location[]
usageLogs UsageLog[]
tags Tag[]
settings UserSettings?
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
}
// ───────────────────────────────────────
// Domain models
// ───────────────────────────────────────
model Vendor {
id String @id @default(cuid())
name String @db.VarChar(64)
website String? @db.VarChar(256)
notes String? @db.Text
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
filaments Filament[]
resins Resin[]
paints Paint[]
@@index([userId])
@@index([archived])
}
model Location {
id String @id @default(cuid())
name String @db.VarChar(64)
description String? @db.VarChar(256)
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
filaments Filament[]
resins Resin[]
paints Paint[]
@@index([userId])
@@index([archived])
}
model Filament {
id String @id @default(cuid())
name String @db.VarChar(128)
brand String @db.VarChar(64)
material String @db.VarChar(32)
color String @db.VarChar(64)
colorHex String @db.VarChar(7)
diameter Float @default(1.75)
spoolWeight Float
usedWeight Float @default(0)
emptySpoolWeight Float @default(0)
purchaseDate DateTime?
cost Float?
notes String? @db.Text
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
vendorId String?
locationId String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
tags TagOnFilament[]
usageLogs UsageLog[]
@@index([userId])
@@index([vendorId])
@@index([locationId])
@@index([material])
@@index([archived])
@@index([brand])
}
model Resin {
id String @id @default(cuid())
name String @db.VarChar(128)
brand String @db.VarChar(64)
resinType String @db.VarChar(32)
color String @db.VarChar(64)
colorHex String @db.VarChar(7)
bottleSize Float
usedML Float @default(0)
purchaseDate DateTime?
cost Float?
notes String? @db.Text
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
vendorId String?
locationId String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
tags TagOnResin[]
usageLogs UsageLog[]
@@index([userId])
@@index([vendorId])
@@index([locationId])
@@index([resinType])
@@index([archived])
@@index([brand])
}
model Paint {
id String @id @default(cuid())
name String @db.VarChar(128)
brand String @db.VarChar(64)
line String? @db.VarChar(64)
color String @db.VarChar(64)
colorHex String @db.VarChar(7)
finish String @db.VarChar(32)
volumeML Float
usedML Float @default(0)
purchaseDate DateTime?
cost Float?
notes String? @db.Text
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
vendorId String?
locationId String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
tags TagOnPaint[]
usageLogs UsageLog[]
@@index([userId])
@@index([vendorId])
@@index([locationId])
@@index([finish])
@@index([archived])
@@index([brand])
}
model Tag {
id String @id @default(cuid())
name String @db.VarChar(64)
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
filaments TagOnFilament[]
resins TagOnResin[]
paints TagOnPaint[]
@@unique([name, userId])
@@index([userId])
}
model TagOnFilament {
filamentId String
tagId String
filament Filament @relation(fields: [filamentId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([filamentId, tagId])
}
model TagOnResin {
resinId String
tagId String
resin Resin @relation(fields: [resinId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([resinId, tagId])
}
model TagOnPaint {
paintId String
tagId String
paint Paint @relation(fields: [paintId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([paintId, tagId])
}
model UsageLog {
id String @id @default(cuid())
itemType String @db.VarChar(16)
itemId String
amount Float
unit String @db.VarChar(4)
notes String? @db.Text
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
filament Filament? @relation(fields: [filamentId], references: [id], onDelete: Cascade)
filamentId String?
resin Resin? @relation(fields: [resinId], references: [id], onDelete: Cascade)
resinId String?
paint Paint? @relation(fields: [paintId], references: [id], onDelete: Cascade)
paintId String?
@@index([userId])
@@index([itemType, itemId])
@@index([createdAt])
}
model UserSettings {
id String @id @default(cuid())
userId String @unique
lowStockThreshold Float @default(10)
currency String @default("USD") @db.VarChar(3)
theme String @default("dark") @db.VarChar(8)
units String @default("metric") @db.VarChar(8)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

633
prisma/seed.ts Normal file
View File

@@ -0,0 +1,633 @@
import { PrismaClient } from "../src/generated/prisma";
import { PrismaPg } from "@prisma/adapter-pg";
import pg from "pg";
import { hash } from "bcryptjs";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log("Seeding database...");
// Create admin user
const adminPassword = await hash("password123", 10);
const admin = await prisma.user.upsert({
where: { email: "admin@dragonsstash.local" },
update: {},
create: {
name: "Admin",
email: "admin@dragonsstash.local",
hashedPassword: adminPassword,
role: "ADMIN",
settings: {
create: {
lowStockThreshold: 10,
currency: "USD",
theme: "dark",
units: "metric",
},
},
},
});
// Create regular user
const userPassword = await hash("password123", 10);
const user = await prisma.user.upsert({
where: { email: "user@dragonsstash.local" },
update: {},
create: {
name: "Demo User",
email: "user@dragonsstash.local",
hashedPassword: userPassword,
role: "USER",
settings: {
create: {
lowStockThreshold: 15,
currency: "EUR",
theme: "dark",
units: "metric",
},
},
},
});
// Create vendors
const vendors = await Promise.all([
prisma.vendor.create({
data: {
name: "Prusament",
website: "https://www.prusa3d.com/category/prusament/",
notes: "Premium filament by Prusa Research",
userId: admin.id,
},
}),
prisma.vendor.create({
data: {
name: "Hatchbox",
website: "https://www.hatchbox3d.com",
notes: "Popular budget-friendly filament brand",
userId: admin.id,
},
}),
prisma.vendor.create({
data: {
name: "Elegoo",
website: "https://www.elegoo.com",
notes: "Resin and printer manufacturer",
userId: admin.id,
},
}),
prisma.vendor.create({
data: {
name: "Citadel",
website: "https://www.games-workshop.com",
notes: "Games Workshop miniature paints",
userId: admin.id,
},
}),
prisma.vendor.create({
data: {
name: "Vallejo",
website: "https://acrilicosvallejo.com",
notes: "Professional model and miniature paints",
userId: admin.id,
},
}),
]);
// Create locations
const locations = await Promise.all([
prisma.location.create({
data: { name: "Shelf A", description: "Main filament storage shelf", userId: admin.id },
}),
prisma.location.create({
data: { name: "Shelf B", description: "Secondary storage", userId: admin.id },
}),
prisma.location.create({
data: { name: "Drawer 1", description: "Paint storage drawer", userId: admin.id },
}),
prisma.location.create({
data: { name: "Drawer 2", description: "Resin and accessories", userId: admin.id },
}),
]);
// Create tags
const tags = await Promise.all([
prisma.tag.create({ data: { name: "favorites", userId: admin.id } }),
prisma.tag.create({ data: { name: "project-x", userId: admin.id } }),
prisma.tag.create({ data: { name: "weathering", userId: admin.id } }),
prisma.tag.create({ data: { name: "terrain", userId: admin.id } }),
prisma.tag.create({ data: { name: "miniatures", userId: admin.id } }),
]);
// Create filaments
const filaments = await Promise.all([
prisma.filament.create({
data: {
name: "Prusament PLA Galaxy Black",
brand: "Prusament",
material: "PLA",
color: "Galaxy Black",
colorHex: "#1a1a2e",
spoolWeight: 1000,
usedWeight: 350,
cost: 29.99,
purchaseDate: new Date("2025-11-01"),
userId: admin.id,
vendorId: vendors[0].id,
locationId: locations[0].id,
tags: { create: [{ tagId: tags[0].id }] },
},
}),
prisma.filament.create({
data: {
name: "Hatchbox PLA True White",
brand: "Hatchbox",
material: "PLA",
color: "True White",
colorHex: "#ffffff",
spoolWeight: 1000,
usedWeight: 800,
cost: 24.99,
purchaseDate: new Date("2025-09-15"),
userId: admin.id,
vendorId: vendors[1].id,
locationId: locations[0].id,
},
}),
prisma.filament.create({
data: {
name: "Prusament PETG Orange",
brand: "Prusament",
material: "PETG",
color: "Orange",
colorHex: "#f97316",
spoolWeight: 1000,
usedWeight: 150,
cost: 32.99,
purchaseDate: new Date("2025-12-01"),
userId: admin.id,
vendorId: vendors[0].id,
locationId: locations[0].id,
tags: { create: [{ tagId: tags[0].id }, { tagId: tags[1].id }] },
},
}),
prisma.filament.create({
data: {
name: "Hatchbox ABS Red",
brand: "Hatchbox",
material: "ABS",
color: "Red",
colorHex: "#dc2626",
spoolWeight: 1000,
usedWeight: 50,
cost: 22.99,
purchaseDate: new Date("2026-01-10"),
userId: admin.id,
vendorId: vendors[1].id,
locationId: locations[1].id,
},
}),
prisma.filament.create({
data: {
name: "Prusament PLA Azure Blue",
brand: "Prusament",
material: "PLA",
color: "Azure Blue",
colorHex: "#3b82f6",
spoolWeight: 1000,
usedWeight: 500,
cost: 29.99,
purchaseDate: new Date("2025-10-20"),
userId: admin.id,
vendorId: vendors[0].id,
locationId: locations[0].id,
},
}),
prisma.filament.create({
data: {
name: "Hatchbox TPU Black",
brand: "Hatchbox",
material: "TPU",
color: "Black",
colorHex: "#0a0a0a",
spoolWeight: 800,
usedWeight: 200,
cost: 27.99,
purchaseDate: new Date("2025-11-15"),
userId: admin.id,
vendorId: vendors[1].id,
locationId: locations[1].id,
},
}),
prisma.filament.create({
data: {
name: "Prusament PLA Lipstick Red",
brand: "Prusament",
material: "PLA",
color: "Lipstick Red",
colorHex: "#e11d48",
spoolWeight: 1000,
usedWeight: 950,
cost: 29.99,
purchaseDate: new Date("2025-08-01"),
notes: "Almost empty, need to reorder",
userId: admin.id,
vendorId: vendors[0].id,
locationId: locations[0].id,
},
}),
prisma.filament.create({
data: {
name: "Hatchbox PETG Transparent",
brand: "Hatchbox",
material: "PETG",
color: "Transparent",
colorHex: "#e2e8f0",
spoolWeight: 1000,
usedWeight: 100,
cost: 25.99,
purchaseDate: new Date("2026-01-20"),
userId: admin.id,
vendorId: vendors[1].id,
locationId: locations[1].id,
},
}),
prisma.filament.create({
data: {
name: "Prusament ASA Signal Orange",
brand: "Prusament",
material: "ASA",
color: "Signal Orange",
colorHex: "#ea580c",
spoolWeight: 850,
usedWeight: 400,
cost: 35.99,
purchaseDate: new Date("2025-10-05"),
userId: admin.id,
vendorId: vendors[0].id,
locationId: locations[0].id,
},
}),
prisma.filament.create({
data: {
name: "Hatchbox PLA Silk Gold",
brand: "Hatchbox",
material: "PLA",
color: "Silk Gold",
colorHex: "#d4a017",
spoolWeight: 1000,
usedWeight: 250,
cost: 26.99,
purchaseDate: new Date("2025-12-15"),
userId: admin.id,
vendorId: vendors[1].id,
locationId: locations[0].id,
tags: { create: [{ tagId: tags[4].id }] },
},
}),
]);
// Create resins
const resins = await Promise.all([
prisma.resin.create({
data: {
name: "Elegoo Standard Grey",
brand: "Elegoo",
resinType: "Standard",
color: "Grey",
colorHex: "#6b7280",
bottleSize: 1000,
usedML: 450,
cost: 29.99,
purchaseDate: new Date("2025-11-10"),
userId: admin.id,
vendorId: vendors[2].id,
locationId: locations[3].id,
},
}),
prisma.resin.create({
data: {
name: "Elegoo ABS-Like Clear Blue",
brand: "Elegoo",
resinType: "ABS-Like",
color: "Clear Blue",
colorHex: "#60a5fa",
bottleSize: 500,
usedML: 350,
cost: 34.99,
purchaseDate: new Date("2025-10-20"),
userId: admin.id,
vendorId: vendors[2].id,
locationId: locations[3].id,
},
}),
prisma.resin.create({
data: {
name: "Elegoo Water-Washable Ceramic Grey",
brand: "Elegoo",
resinType: "Water-Washable",
color: "Ceramic Grey",
colorHex: "#9ca3af",
bottleSize: 1000,
usedML: 100,
cost: 36.99,
purchaseDate: new Date("2026-01-05"),
userId: admin.id,
vendorId: vendors[2].id,
locationId: locations[3].id,
tags: { create: [{ tagId: tags[4].id }] },
},
}),
prisma.resin.create({
data: {
name: "Elegoo Flexible Black",
brand: "Elegoo",
resinType: "Flexible",
color: "Black",
colorHex: "#171717",
bottleSize: 500,
usedML: 480,
cost: 39.99,
purchaseDate: new Date("2025-09-01"),
notes: "Nearly empty",
userId: admin.id,
vendorId: vendors[2].id,
locationId: locations[3].id,
},
}),
prisma.resin.create({
data: {
name: "Elegoo Tough White",
brand: "Elegoo",
resinType: "Tough",
color: "White",
colorHex: "#f5f5f5",
bottleSize: 1000,
usedML: 200,
cost: 42.99,
purchaseDate: new Date("2025-12-20"),
userId: admin.id,
vendorId: vendors[2].id,
locationId: locations[3].id,
},
}),
]);
// Create paints
const paints = await Promise.all([
prisma.paint.create({
data: {
name: "Abaddon Black",
brand: "Citadel",
line: "Base",
color: "Black",
colorHex: "#231f20",
finish: "Matte",
volumeML: 12,
usedML: 6,
cost: 5.49,
purchaseDate: new Date("2025-10-01"),
userId: admin.id,
vendorId: vendors[3].id,
locationId: locations[2].id,
tags: { create: [{ tagId: tags[4].id }] },
},
}),
prisma.paint.create({
data: {
name: "Mephiston Red",
brand: "Citadel",
line: "Base",
color: "Red",
colorHex: "#9a1115",
finish: "Matte",
volumeML: 12,
usedML: 3,
cost: 5.49,
purchaseDate: new Date("2025-10-01"),
userId: admin.id,
vendorId: vendors[3].id,
locationId: locations[2].id,
},
}),
prisma.paint.create({
data: {
name: "Retributor Armour",
brand: "Citadel",
line: "Base",
color: "Gold",
colorHex: "#c39e5a",
finish: "Metallic",
volumeML: 12,
usedML: 8,
cost: 5.49,
purchaseDate: new Date("2025-09-15"),
userId: admin.id,
vendorId: vendors[3].id,
locationId: locations[2].id,
},
}),
prisma.paint.create({
data: {
name: "Nuln Oil",
brand: "Citadel",
line: "Shade",
color: "Black",
colorHex: "#14120e",
finish: "Wash",
volumeML: 24,
usedML: 10,
cost: 7.99,
purchaseDate: new Date("2025-10-01"),
userId: admin.id,
vendorId: vendors[3].id,
locationId: locations[2].id,
tags: { create: [{ tagId: tags[2].id }] },
},
}),
prisma.paint.create({
data: {
name: "Agrax Earthshade",
brand: "Citadel",
line: "Shade",
color: "Brown",
colorHex: "#4b3620",
finish: "Wash",
volumeML: 24,
usedML: 15,
cost: 7.99,
purchaseDate: new Date("2025-08-20"),
userId: admin.id,
vendorId: vendors[3].id,
locationId: locations[2].id,
tags: { create: [{ tagId: tags[2].id }, { tagId: tags[3].id }] },
},
}),
prisma.paint.create({
data: {
name: "Model Color White",
brand: "Vallejo",
line: "Model Color",
color: "White",
colorHex: "#f8f8f8",
finish: "Matte",
volumeML: 17,
usedML: 5,
cost: 3.99,
purchaseDate: new Date("2025-11-01"),
userId: admin.id,
vendorId: vendors[4].id,
locationId: locations[2].id,
},
}),
prisma.paint.create({
data: {
name: "Model Color German Grey",
brand: "Vallejo",
line: "Model Color",
color: "German Grey",
colorHex: "#4a4a4a",
finish: "Matte",
volumeML: 17,
usedML: 2,
cost: 3.99,
purchaseDate: new Date("2025-11-01"),
userId: admin.id,
vendorId: vendors[4].id,
locationId: locations[2].id,
},
}),
prisma.paint.create({
data: {
name: "Contrast Blood Angels Red",
brand: "Citadel",
line: "Contrast",
color: "Red",
colorHex: "#c01411",
finish: "Contrast",
volumeML: 18,
usedML: 12,
cost: 7.99,
purchaseDate: new Date("2025-09-10"),
userId: admin.id,
vendorId: vendors[3].id,
locationId: locations[2].id,
},
}),
prisma.paint.create({
data: {
name: "Leadbelcher",
brand: "Citadel",
line: "Base",
color: "Silver",
colorHex: "#a8a8a8",
finish: "Metallic",
volumeML: 12,
usedML: 11,
cost: 5.49,
purchaseDate: new Date("2025-07-01"),
notes: "Almost empty, reorder soon",
userId: admin.id,
vendorId: vendors[3].id,
locationId: locations[2].id,
},
}),
prisma.paint.create({
data: {
name: "Surface Primer Black",
brand: "Vallejo",
line: "Surface Primer",
color: "Black",
colorHex: "#1a1a1a",
finish: "Primer",
volumeML: 60,
usedML: 20,
cost: 9.99,
purchaseDate: new Date("2025-12-01"),
userId: admin.id,
vendorId: vendors[4].id,
locationId: locations[2].id,
},
}),
]);
// Create some usage logs
await Promise.all([
prisma.usageLog.create({
data: {
itemType: "FILAMENT",
itemId: filaments[0].id,
filamentId: filaments[0].id,
amount: 50,
unit: "g",
notes: "Printed phone stand",
userId: admin.id,
},
}),
prisma.usageLog.create({
data: {
itemType: "FILAMENT",
itemId: filaments[2].id,
filamentId: filaments[2].id,
amount: 100,
unit: "g",
notes: "Printed enclosure parts",
userId: admin.id,
},
}),
prisma.usageLog.create({
data: {
itemType: "RESIN",
itemId: resins[0].id,
resinId: resins[0].id,
amount: 150,
unit: "ml",
notes: "Printed miniature batch",
userId: admin.id,
},
}),
prisma.usageLog.create({
data: {
itemType: "PAINT",
itemId: paints[0].id,
paintId: paints[0].id,
amount: 2,
unit: "ml",
notes: "Base coated 5 miniatures",
userId: admin.id,
},
}),
prisma.usageLog.create({
data: {
itemType: "PAINT",
itemId: paints[3].id,
paintId: paints[3].id,
amount: 3,
unit: "ml",
notes: "Washed batch of 10 infantry",
userId: admin.id,
},
}),
]);
console.log("Database seeded successfully!");
console.log(` Admin: admin@dragonsstash.local / password123`);
console.log(` User: user@dragonsstash.local / password123`);
console.log(` Vendors: ${vendors.length}`);
console.log(` Locations: ${locations.length}`);
console.log(` Filaments: ${filaments.length}`);
console.log(` Resins: ${resins.length}`);
console.log(` Paints: ${paints.length}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

246
scripts/fetch-paint-data.ts Normal file
View File

@@ -0,0 +1,246 @@
/**
* Fetches miniature paint data from the Arcturus5404/miniature-paints GitHub repo
* and converts Markdown tables into a single JSON file for the catalog API.
*
* Usage: npx tsx scripts/fetch-paint-data.ts
*/
import { writeFileSync, mkdirSync } from "fs";
import { resolve } from "path";
const GITHUB_RAW =
"https://raw.githubusercontent.com/Arcturus5404/miniature-paints/main/paints";
// Brands to fetch — file names from the repo (without .md)
const BRANDS = [
"AK",
"Army_Painter",
"Citadel_Colour",
"CoatDArmes",
"Foundry",
"GreenStuffWorld",
"Humbrol",
"KimeraKolors",
"Mig",
"MissionModels",
"Monument",
"MrHobby",
"P3",
"Reaper",
"Revell",
"Scale75",
"Tamiya",
"TurboDork",
"Vallejo",
"Warcolours",
];
// Display names for brands (file name → human-friendly)
const BRAND_NAMES: Record<string, string> = {
AK: "AK Interactive",
Army_Painter: "The Army Painter",
Citadel_Colour: "Citadel",
CoatDArmes: "Coat d'Armes",
Foundry: "Foundry",
GreenStuffWorld: "Green Stuff World",
Humbrol: "Humbrol",
KimeraKolors: "Kimera Kolors",
Mig: "AMMO by MIG",
MissionModels: "Mission Models",
Monument: "Monument Hobbies",
MrHobby: "Mr. Hobby",
P3: "P3 (Privateer Press)",
Reaper: "Reaper",
Revell: "Revell",
Scale75: "Scale75",
Tamiya: "Tamiya",
TurboDork: "TurboDork",
Vallejo: "Vallejo",
Warcolours: "Warcolours",
};
// Map known range/set names to paint finish types
const FINISH_MAP: Record<string, string> = {
// Citadel
base: "Matte",
layer: "Matte",
air: "Matte",
dry: "Matte",
shade: "Wash",
contrast: "Contrast",
technical: "Other",
"foundation (discontinued)": "Matte",
"goblin green (discontinued)": "Matte",
// Army Painter
warpaints: "Matte",
"warpaints fanatic": "Matte",
"warpaints air": "Matte",
speedpaint: "Contrast",
"speedpaint set": "Contrast",
"speedpaint set 2.0": "Contrast",
"warpaints washes": "Wash",
"warpaints effects": "Other",
"warpaints metallics": "Metallic",
"warpaints primer": "Primer",
// Vallejo
"model color": "Matte",
"model air": "Matte",
"game color": "Matte",
"game air": "Matte",
"game ink": "Ink",
"game wash": "Wash",
"metal color": "Metallic",
"mecha color": "Matte",
"mecha varnish": "Varnish",
"surface primer": "Primer",
"xpress color": "Contrast",
panzer: "Matte",
// Generic
metallic: "Metallic",
metallics: "Metallic",
wash: "Wash",
washes: "Wash",
ink: "Ink",
inks: "Ink",
primer: "Primer",
varnish: "Varnish",
};
interface PaintEntry {
id: string;
name: string;
brand: string;
type: "paint";
color: string;
colorHex: string;
line: string;
finish: string;
productCode: string | null;
}
function extractHex(hexCell: string): string | null {
// Format: ![#HEXHEX](url) `#HEXHEX` — extract from backtick code
const backtickMatch = hexCell.match(/`(#[0-9A-Fa-f]{6})`/);
if (backtickMatch) return backtickMatch[1];
// Fallback: raw hex
const rawMatch = hexCell.match(/#[0-9A-Fa-f]{6}/);
if (rawMatch) return rawMatch[0];
return null;
}
function guessFinish(setName: string): string {
const lower = setName.toLowerCase().trim();
// Direct match
if (FINISH_MAP[lower]) return FINISH_MAP[lower];
// Partial match
for (const [key, value] of Object.entries(FINISH_MAP)) {
if (lower.includes(key)) return value;
}
return "Matte"; // Default
}
function parseMarkdownTable(markdown: string, brandFile: string): PaintEntry[] {
const brandName = BRAND_NAMES[brandFile] || brandFile.replace(/_/g, " ");
const lines = markdown.split("\n").filter((l) => l.trim().startsWith("|"));
if (lines.length < 2) return [];
// Parse header to determine column layout
const header = lines[0]
.split("|")
.map((c) => c.trim().toLowerCase())
.filter(Boolean);
const hasCode = header.includes("code");
// Determine column indices
const nameIdx = 0;
const codeIdx = hasCode ? 1 : -1;
const setIdx = hasCode ? 2 : 1;
const rIdx = hasCode ? 3 : 2;
const gIdx = hasCode ? 4 : 3;
const bIdx = hasCode ? 5 : 4;
const hexIdx = hasCode ? 6 : 5;
const entries: PaintEntry[] = [];
// Skip header and separator rows
for (let i = 2; i < lines.length; i++) {
const cells = lines[i]
.split("|")
.map((c) => c.trim())
.filter(Boolean);
if (cells.length < (hasCode ? 7 : 6)) continue;
const name = cells[nameIdx];
const code = codeIdx >= 0 ? cells[codeIdx] : null;
const set = cells[setIdx] || "";
const hex = extractHex(cells[hexIdx]);
if (!name || !hex) continue;
const finish = guessFinish(set);
const id = `paint-${brandFile}-${name}-${set}`.replace(/[^a-zA-Z0-9-]/g, "_").toLowerCase();
entries.push({
id,
name,
brand: brandName,
type: "paint",
color: name, // For paints, the name IS the color
colorHex: hex,
line: set,
finish,
productCode: code && code !== "null" ? code : null,
});
}
return entries;
}
async function fetchBrand(brandFile: string): Promise<PaintEntry[]> {
const url = `${GITHUB_RAW}/${brandFile}.md`;
try {
const resp = await fetch(url);
if (!resp.ok) {
console.warn(` ⚠ Failed to fetch ${brandFile}: ${resp.status}`);
return [];
}
const md = await resp.text();
const entries = parseMarkdownTable(md, brandFile);
console.log(`${BRAND_NAMES[brandFile] || brandFile}: ${entries.length} paints`);
return entries;
} catch (err) {
console.warn(` ⚠ Error fetching ${brandFile}:`, err);
return [];
}
}
async function main() {
console.log("Fetching paint data from GitHub...\n");
const allEntries: PaintEntry[] = [];
// Fetch in batches of 5 to avoid rate limits
for (let i = 0; i < BRANDS.length; i += 5) {
const batch = BRANDS.slice(i, i + 5);
const results = await Promise.all(batch.map(fetchBrand));
allEntries.push(...results.flat());
}
console.log(`\nTotal: ${allEntries.length} paints from ${BRANDS.length} brands`);
// Write JSON
const outDir = resolve(__dirname, "../src/data/catalog");
mkdirSync(outDir, { recursive: true });
const outPath = resolve(outDir, "paints.json");
writeFileSync(outPath, JSON.stringify(allEntries, null, 2));
console.log(`\nWritten to: ${outPath}`);
}
main().catch(console.error);

View File

@@ -0,0 +1,43 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
export default function DashboardLoading() {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border-border">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-7 w-16" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-10 w-10 rounded-md" />
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border-border">
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card className="border-border">
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getDashboardStats } from "@/data/dashboard.queries";
import { getUserSettings } from "@/data/settings.queries";
import { Package, DollarSign, AlertTriangle, Activity } from "lucide-react";
import { StatCard } from "@/components/shared/stat-card";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const settings = await getUserSettings(session.user.id);
const stats = await getDashboardStats(session.user.id, settings.lowStockThreshold);
const currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: settings.currency,
});
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Items"
value={stats.totalItems}
icon={Package}
description="Filaments, resins & paints"
/>
<StatCard
title="Inventory Value"
value={currencyFormatter.format(stats.inventoryValue)}
icon={DollarSign}
description="Total purchase cost"
/>
<StatCard
title="Low Stock"
value={stats.lowStockCount}
icon={AlertTriangle}
description={`Below ${settings.lowStockThreshold}% remaining`}
iconClassName={stats.lowStockCount > 0 ? "text-orange-400" : undefined}
/>
<StatCard
title="Recent Activity"
value={stats.recentActivityCount}
icon={Activity}
description="Usage logs in last 24h"
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Low Stock Alerts */}
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base">Low Stock Alerts</CardTitle>
<CardDescription>Items below {settings.lowStockThreshold}% remaining</CardDescription>
</CardHeader>
<CardContent>
{stats.lowStockItems.length === 0 ? (
<p className="text-sm text-muted-foreground">All items are well stocked.</p>
) : (
<div className="space-y-3">
{stats.lowStockItems.map((item) => (
<div key={`${item.type}-${item.id}`} className="flex items-center gap-3">
<ColorSwatch hex={item.colorHex} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.name}</p>
<p className="text-xs text-muted-foreground capitalize">{item.type}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-orange-400">
{Math.round(item.remaining)}
{item.type === "filament" ? "g" : "ml"}
</p>
<p className="text-xs text-muted-foreground">{Math.round(item.percent)}% left</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent Usage */}
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Usage</CardTitle>
<CardDescription>Latest consumption log entries</CardDescription>
</CardHeader>
<CardContent>
{stats.recentUsage.length === 0 ? (
<p className="text-sm text-muted-foreground">No usage logged yet.</p>
) : (
<div className="space-y-3">
{stats.recentUsage.map((log) => (
<div key={log.id} className="flex items-center gap-3">
<Badge variant="outline" className="text-[10px] shrink-0">
{log.itemType}
</Badge>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{log.itemName}</p>
<p className="text-xs text-muted-foreground">
{log.notes || "No notes"}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium">
-{log.amount}{log.unit}
</p>
<p className="text-xs text-muted-foreground">
{new Date(log.createdAt).toLocaleDateString()}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Archive, Trash2, Plus } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { StatusBadge, getStockStatus } from "@/components/shared/status-badge";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface FilamentRow {
id: string;
name: string;
brand: string;
material: string;
color: string;
colorHex: string;
spoolWeight: number;
usedWeight: number;
cost: number | null;
purchaseDate: Date | null;
archived: boolean;
vendor: { id: string; name: string } | null;
location: { id: string; name: string } | null;
tags: { tag: { id: string; name: string } }[];
}
interface FilamentColumnsProps {
onEdit: (filament: FilamentRow) => void;
onArchive: (id: string) => void;
onDelete: (id: string) => void;
onLogUsage: (filament: FilamentRow) => void;
lowStockThreshold: number;
}
export function getFilamentColumns({
onEdit,
onArchive,
onDelete,
onLogUsage,
lowStockThreshold,
}: FilamentColumnsProps): ColumnDef<FilamentRow, unknown>[] {
return [
{
id: "colorPreview",
header: "",
cell: ({ row }) => <ColorSwatch hex={row.original.colorHex} size="sm" />,
enableHiding: false,
enableSorting: false,
size: 40,
},
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.archived && <StatusBadge variant="archived" />}
</div>
),
enableHiding: false,
},
{
accessorKey: "brand",
header: ({ column }) => <DataTableColumnHeader column={column} title="Brand" />,
cell: ({ row }) => <span className="text-sm">{row.original.brand}</span>,
},
{
accessorKey: "material",
header: ({ column }) => <DataTableColumnHeader column={column} title="Material" />,
cell: ({ row }) => (
<Badge variant="secondary" className="text-[10px]">
{row.original.material}
</Badge>
),
},
{
id: "remaining",
header: ({ column }) => <DataTableColumnHeader column={column} title="Remaining" />,
cell: ({ row }) => {
const remaining = row.original.spoolWeight - row.original.usedWeight;
const percent = row.original.spoolWeight > 0
? Math.round((remaining / row.original.spoolWeight) * 100)
: 0;
const status = getStockStatus(remaining, row.original.spoolWeight, lowStockThreshold, row.original.archived);
return (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 rounded-full bg-muted">
<div
className={`h-full rounded-full ${
status === "lowStock" || status === "empty"
? "bg-orange-500"
: "bg-emerald-500"
}`}
style={{ width: `${Math.max(0, Math.min(100, percent))}%` }}
/>
</div>
<span className="text-sm text-muted-foreground">
{Math.round(remaining)}g ({percent}%)
</span>
{(status === "lowStock" || status === "empty") && !row.original.archived && (
<StatusBadge variant={status} />
)}
</div>
);
},
accessorFn: (row) => row.spoolWeight - row.usedWeight,
},
{
id: "location",
header: ({ column }) => <DataTableColumnHeader column={column} title="Location" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.location?.name || "—"}
</span>
),
accessorFn: (row) => row.location?.name,
},
{
id: "vendor",
header: ({ column }) => <DataTableColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.vendor?.name || "—"}
</span>
),
accessorFn: (row) => row.vendor?.name,
},
{
accessorKey: "cost",
header: ({ column }) => <DataTableColumnHeader column={column} title="Cost" />,
cell: ({ row }) => (
<span className="text-sm">
{row.original.cost != null ? `$${row.original.cost.toFixed(2)}` : "—"}
</span>
),
},
{
accessorKey: "purchaseDate",
header: ({ column }) => <DataTableColumnHeader column={column} title="Purchased" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.purchaseDate
? new Date(row.original.purchaseDate).toLocaleDateString()
: "—"}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onLogUsage(row.original)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Log Usage
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onArchive(row.original.id)}>
<Archive className="mr-2 h-3.5 w-3.5" />
{row.original.archived ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row.original.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,361 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { filamentSchema, type FilamentInput } from "@/schemas/filament.schema";
import { MATERIALS } from "@/lib/constants";
import { createFilament, updateFilament } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { CatalogBrowserButton } from "@/components/shared/catalog-browser-button";
import { AutocompleteInput } from "@/components/shared/autocomplete-input";
import type { CatalogItem } from "@/types/catalog.types";
interface FilamentFormProps {
filament?: {
id: string;
name: string;
brand: string;
material: string;
color: string;
colorHex: string;
diameter: number;
spoolWeight: number;
usedWeight: number;
emptySpoolWeight: number;
cost: number | null;
purchaseDate: Date | null;
notes: string | null;
vendorId: string | null;
locationId: string | null;
};
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
onSuccess: () => void;
}
export function FilamentForm({ filament, vendors, locations, onSuccess }: FilamentFormProps) {
const [isPending, startTransition] = useTransition();
const isEditing = !!filament;
const form = useForm<FilamentInput>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(filamentSchema) as any,
defaultValues: {
name: filament?.name ?? "",
brand: filament?.brand ?? "",
material: (filament?.material as FilamentInput["material"]) ?? "PLA",
color: filament?.color ?? "",
colorHex: filament?.colorHex ?? "#000000",
diameter: filament?.diameter ?? 1.75,
spoolWeight: filament?.spoolWeight ?? 1000,
usedWeight: filament?.usedWeight ?? 0,
emptySpoolWeight: filament?.emptySpoolWeight ?? 0,
cost: filament?.cost ?? undefined,
purchaseDate: filament?.purchaseDate
? new Date(filament.purchaseDate).toISOString().split("T")[0]
: "",
notes: filament?.notes ?? "",
vendorId: filament?.vendorId ?? "",
locationId: filament?.locationId ?? "",
},
});
const watchColorHex = form.watch("colorHex");
function handleCatalogSelect(item: CatalogItem) {
form.setValue("name", item.name);
form.setValue("brand", item.brand);
if (item.color) form.setValue("color", item.color);
if (item.colorHex) form.setValue("colorHex", item.colorHex);
if (item.material) {
const match = MATERIALS.find(
(m) => m.toUpperCase() === item.material!.toUpperCase(),
);
if (match) form.setValue("material", match);
}
if (item.weight) form.setValue("spoolWeight", item.weight);
if (item.price != null) form.setValue("cost", item.price);
}
function onSubmit(values: FilamentInput) {
startTransition(async () => {
const result = isEditing
? await updateFilament(filament!.id, values)
: await createFilament(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(isEditing ? "Filament updated" : "Filament created");
form.reset();
onSuccess();
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{!isEditing && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Auto-fill from product catalog
</p>
<CatalogBrowserButton type="filament" onSelect={handleCatalogSelect} />
</div>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Name</FormLabel>
<FormControl>
{!isEditing ? (
<AutocompleteInput
type="filament"
value={field.value}
onChange={field.onChange}
onSelectItem={handleCatalogSelect}
placeholder="Filament name — type to search catalog"
/>
) : (
<Input placeholder="Filament name" {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input placeholder="Brand name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="material"
render={({ field }) => (
<FormItem>
<FormLabel>Material</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select material" />
</SelectTrigger>
</FormControl>
<SelectContent>
{MATERIALS.map((m) => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>Color Name</FormLabel>
<FormControl>
<Input placeholder="e.g. Galaxy Black" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="colorHex"
render={({ field }) => (
<FormItem>
<FormLabel>Color Hex</FormLabel>
<div className="flex items-center gap-2">
<FormControl>
<Input placeholder="#000000" {...field} className="flex-1" />
</FormControl>
<input
type="color"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
className="h-9 w-9 cursor-pointer rounded border border-border bg-transparent p-0.5"
/>
<ColorSwatch hex={watchColorHex || "#000000"} size="md" />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="spoolWeight"
render={({ field }) => (
<FormItem>
<FormLabel>Spool Weight (g)</FormLabel>
<FormControl>
<Input type="number" step="1" min="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="usedWeight"
render={({ field }) => (
<FormItem>
<FormLabel>Used Weight (g)</FormLabel>
<FormControl>
<Input type="number" step="0.1" min="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vendorId"
render={({ field }) => (
<FormItem>
<FormLabel>Vendor</FormLabel>
<Select
onValueChange={(val) => field.onChange(val === "none" ? "" : val)}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select vendor" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{vendors.map((v) => (
<SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locationId"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<Select
onValueChange={(val) => field.onChange(val === "none" ? "" : val)}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select location" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{locations.map((l) => (
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cost"
render={({ field }) => (
<FormItem>
<FormLabel>Cost</FormLabel>
<FormControl>
<Input type="number" step="0.01" min="0" placeholder="0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="purchaseDate"
render={({ field }) => (
<FormItem>
<FormLabel>Purchase Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea placeholder="Optional notes" rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FilamentForm } from "./filament-form";
interface FilamentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filament?: Parameters<typeof FilamentForm>[0]["filament"];
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
}
export function FilamentModal({
open,
onOpenChange,
filament,
vendors,
locations,
}: FilamentModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>{filament ? "Edit Filament" : "Add Filament"}</DialogTitle>
<DialogDescription>
{filament ? "Update the filament details below." : "Add a new filament spool to your inventory."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-4">
<FilamentForm
filament={filament}
vendors={vendors}
locations={locations}
onSuccess={() => onOpenChange(false)}
/>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { useState, useTransition, useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { toast } from "sonner";
import { useDataTable } from "@/hooks/use-data-table";
import { MATERIALS } from "@/lib/constants";
import { getFilamentColumns, type FilamentRow } from "./filament-columns";
import { FilamentModal } from "./filament-modal";
import { deleteFilament, archiveFilament, logFilamentUsage } from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
import { DataTableFacetedFilter } from "@/components/shared/data-table-faceted-filter";
import { DeleteDialog } from "@/components/shared/delete-dialog";
import { UsageLogDialog } from "@/components/shared/usage-log-dialog";
import { PageHeader } from "@/components/shared/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface FilamentTableProps {
data: FilamentRow[];
pageCount: number;
totalCount: number;
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
lowStockThreshold: number;
}
export function FilamentTable({
data,
pageCount,
totalCount,
vendors,
locations,
lowStockThreshold,
}: FilamentTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [editFilament, setEditFilament] = useState<FilamentRow | undefined>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [usageFilament, setUsageFilament] = useState<FilamentRow | null>(null);
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
// Filter state from URL
const materialFilter = new Set(searchParams.getAll("material"));
const vendorFilter = new Set(searchParams.getAll("vendor"));
const locationFilter = new Set(searchParams.getAll("location"));
const updateFilters = useCallback(
(key: string, values: Set<string>) => {
const params = new URLSearchParams(searchParams.toString());
params.delete(key);
values.forEach((v) => params.append(key, v));
params.set("page", "1");
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
const updateSearch = (value: string) => {
setSearchValue(value);
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("search", value);
params.set("page", "1");
} else {
params.delete("search");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const columns = getFilamentColumns({
onEdit: (filament) => {
setEditFilament(filament);
setModalOpen(true);
},
onArchive: (id) => {
startTransition(async () => {
const result = await archiveFilament(id);
if (result.success) toast.success("Filament updated");
else toast.error(result.error);
});
},
onDelete: (id) => setDeleteId(id),
onLogUsage: (filament) => setUsageFilament(filament),
lowStockThreshold,
});
const { table } = useDataTable({ data, columns, pageCount });
const handleDelete = () => {
if (!deleteId) return;
startTransition(async () => {
const result = await deleteFilament(deleteId);
if (result.success) {
toast.success("Filament deleted");
setDeleteId(null);
} else {
toast.error(result.error);
}
});
};
const materialOptions = MATERIALS.map((m) => ({ label: m, value: m }));
const vendorOptions = vendors.map((v) => ({ label: v.name, value: v.id }));
const locationOptions = locations.map((l) => ({ label: l.name, value: l.id }));
return (
<div className="space-y-4">
<PageHeader title="Filaments" description="Manage your 3D printing filament inventory">
<Button
onClick={() => {
setEditFilament(undefined);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add Filament
</Button>
</PageHeader>
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search filaments..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DataTableFacetedFilter
title="Material"
options={materialOptions}
selectedValues={materialFilter}
onSelectionChange={(values) => updateFilters("material", values)}
/>
<DataTableFacetedFilter
title="Vendor"
options={vendorOptions}
selectedValues={vendorFilter}
onSelectionChange={(values) => updateFilters("vendor", values)}
/>
<DataTableFacetedFilter
title="Location"
options={locationOptions}
selectedValues={locationFilter}
onSelectionChange={(values) => updateFilters("location", values)}
/>
<DataTableViewOptions table={table} />
</div>
<DataTable table={table} emptyMessage="No filaments found. Add your first spool!" />
<DataTablePagination table={table} totalCount={totalCount} />
<FilamentModal
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) setEditFilament(undefined);
}}
filament={
editFilament
? {
id: editFilament.id,
name: editFilament.name,
brand: editFilament.brand,
material: editFilament.material,
color: editFilament.color,
colorHex: editFilament.colorHex,
diameter: 1.75,
spoolWeight: editFilament.spoolWeight,
usedWeight: editFilament.usedWeight,
emptySpoolWeight: 0,
cost: editFilament.cost,
purchaseDate: editFilament.purchaseDate,
notes: null,
vendorId: editFilament.vendor?.id ?? null,
locationId: editFilament.location?.id ?? null,
}
: undefined
}
vendors={vendors}
locations={locations}
/>
<DeleteDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
title="Delete Filament"
description="This will permanently delete this filament spool and all its usage logs."
onConfirm={handleDelete}
isLoading={isPending}
/>
{usageFilament && (
<UsageLogDialog
open={!!usageFilament}
onOpenChange={(open) => !open && setUsageFilament(null)}
itemName={usageFilament.name}
unit="g"
onSubmit={async (amount, notes) => {
const result = await logFilamentUsage(usageFilament.id, { amount, notes });
return result;
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,168 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { filamentSchema } from "@/schemas/filament.schema";
import { usageLogSchema } from "@/schemas/usage-log.schema";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
export async function createFilament(input: unknown): Promise<ActionResult<{ id: string }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = filamentSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
try {
const filament = await prisma.filament.create({
data: {
name: parsed.data.name,
brand: parsed.data.brand,
material: parsed.data.material,
color: parsed.data.color,
colorHex: parsed.data.colorHex,
diameter: parsed.data.diameter,
spoolWeight: parsed.data.spoolWeight,
usedWeight: parsed.data.usedWeight,
emptySpoolWeight: parsed.data.emptySpoolWeight,
purchaseDate: parsed.data.purchaseDate ? new Date(parsed.data.purchaseDate) : null,
cost: parsed.data.cost ?? null,
notes: parsed.data.notes || null,
vendorId: parsed.data.vendorId || null,
locationId: parsed.data.locationId || null,
userId: session.user.id,
},
});
revalidatePath("/filaments");
revalidatePath("/dashboard");
return { success: true, data: { id: filament.id } };
} catch {
return { success: false, error: "Failed to create filament" };
}
}
export async function updateFilament(id: string, input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = filamentSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
const existing = await prisma.filament.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.filament.update({
where: { id },
data: {
name: parsed.data.name,
brand: parsed.data.brand,
material: parsed.data.material,
color: parsed.data.color,
colorHex: parsed.data.colorHex,
diameter: parsed.data.diameter,
spoolWeight: parsed.data.spoolWeight,
usedWeight: parsed.data.usedWeight,
emptySpoolWeight: parsed.data.emptySpoolWeight,
purchaseDate: parsed.data.purchaseDate ? new Date(parsed.data.purchaseDate) : null,
cost: parsed.data.cost ?? null,
notes: parsed.data.notes || null,
vendorId: parsed.data.vendorId || null,
locationId: parsed.data.locationId || null,
},
});
revalidatePath("/filaments");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update filament" };
}
}
export async function deleteFilament(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.filament.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.filament.delete({ where: { id } });
revalidatePath("/filaments");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete filament" };
}
}
export async function archiveFilament(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.filament.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.filament.update({
where: { id },
data: { archived: !existing.archived },
});
revalidatePath("/filaments");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to archive filament" };
}
}
export async function logFilamentUsage(
filamentId: string,
input: unknown
): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = usageLogSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
const existing = await prisma.filament.findFirst({
where: { id: filamentId, userId: session.user.id },
});
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.$transaction([
prisma.usageLog.create({
data: {
itemType: "FILAMENT",
itemId: filamentId,
filamentId,
amount: parsed.data.amount,
unit: "g",
notes: parsed.data.notes || null,
userId: session.user.id,
},
}),
prisma.filament.update({
where: { id: filamentId },
data: { usedWeight: { increment: parsed.data.amount } },
}),
]);
revalidatePath("/filaments");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to log usage" };
}
}

View File

@@ -0,0 +1,24 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function FilamentsLoading() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-9 w-32" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<Skeleton className="h-10 w-full" />
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getFilaments } from "@/data/filament.queries";
import { getVendorOptions } from "@/data/vendor.queries";
import { getLocationOptions } from "@/data/location.queries";
import { getUserSettings } from "@/data/settings.queries";
import type { DataTableSearchParams } from "@/types/table.types";
import { FilamentTable } from "./_components/filament-table";
interface Props {
searchParams: Promise<DataTableSearchParams>;
}
export default async function FilamentsPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const params = await searchParams;
const [result, vendors, locations, settings] = await Promise.all([
getFilaments(session.user.id, params),
getVendorOptions(session.user.id),
getLocationOptions(session.user.id),
getUserSettings(session.user.id),
]);
return (
<FilamentTable
data={JSON.parse(JSON.stringify(result.data))}
pageCount={result.pageCount}
totalCount={result.totalCount}
vendors={vendors}
locations={locations}
lowStockThreshold={settings.lowStockThreshold}
/>
);
}

16
src/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen overflow-hidden">
<div className="hidden lg:block">
<Sidebar />
</div>
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-4 lg:p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Archive, Trash2 } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { StatusBadge } from "@/components/shared/status-badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface LocationRow {
id: string;
name: string;
description: string | null;
archived: boolean;
createdAt: Date;
_count: { filaments: number; resins: number; paints: number };
}
interface LocationColumnsProps {
onEdit: (location: LocationRow) => void;
onArchive: (id: string) => void;
onDelete: (id: string) => void;
}
export function getLocationColumns({
onEdit,
onArchive,
onDelete,
}: LocationColumnsProps): ColumnDef<LocationRow, unknown>[] {
return [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.archived && <StatusBadge variant="archived" />}
</div>
),
enableHiding: false,
},
{
accessorKey: "description",
header: ({ column }) => <DataTableColumnHeader column={column} title="Description" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground truncate max-w-[300px] block">
{row.original.description || "\u2014"}
</span>
),
},
{
id: "items",
header: "Items",
cell: ({ row }) => {
const c = row.original._count;
const total = c.filaments + c.resins + c.paints;
return (
<span className="text-sm text-muted-foreground">{total}</span>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onArchive(row.original.id)}>
<Archive className="mr-2 h-3.5 w-3.5" />
{row.original.archived ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row.original.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { locationSchema, type LocationInput } from "@/schemas/location.schema";
import { createLocation, updateLocation } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
interface LocationFormProps {
location?: { id: string; name: string; description: string | null };
onSuccess: () => void;
}
export function LocationForm({ location, onSuccess }: LocationFormProps) {
const [isPending, startTransition] = useTransition();
const isEditing = !!location;
const form = useForm<LocationInput>({
resolver: zodResolver(locationSchema),
defaultValues: {
name: location?.name ?? "",
description: location?.description ?? "",
},
});
function onSubmit(values: LocationInput) {
startTransition(async () => {
const result = isEditing
? await updateLocation(location!.id, values)
: await createLocation(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(isEditing ? "Location updated" : "Location created");
form.reset();
onSuccess();
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Location name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Optional description" rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { LocationForm } from "./location-form";
interface LocationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
location?: { id: string; name: string; description: string | null };
}
export function LocationModal({ open, onOpenChange, location }: LocationModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{location ? "Edit Location" : "Add Location"}</DialogTitle>
<DialogDescription>
{location
? "Update the location details below."
: "Add a new storage location."}
</DialogDescription>
</DialogHeader>
<LocationForm location={location} onSuccess={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { toast } from "sonner";
import { useDataTable } from "@/hooks/use-data-table";
import { getLocationColumns, type LocationRow } from "./location-columns";
import { LocationModal } from "./location-modal";
import { deleteLocation, archiveLocation } from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
import { DeleteDialog } from "@/components/shared/delete-dialog";
import { PageHeader } from "@/components/shared/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface LocationTableProps {
data: LocationRow[];
pageCount: number;
totalCount: number;
}
export function LocationTable({ data, pageCount, totalCount }: LocationTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [editLocation, setEditLocation] = useState<LocationRow | undefined>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const updateSearch = (value: string) => {
setSearchValue(value);
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("search", value);
params.set("page", "1");
} else {
params.delete("search");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const columns = getLocationColumns({
onEdit: (location) => {
setEditLocation(location);
setModalOpen(true);
},
onArchive: (id) => {
startTransition(async () => {
const result = await archiveLocation(id);
if (result.success) toast.success("Location updated");
else toast.error(result.error);
});
},
onDelete: (id) => setDeleteId(id),
});
const { table } = useDataTable({ data, columns, pageCount });
const handleDelete = () => {
if (!deleteId) return;
startTransition(async () => {
const result = await deleteLocation(deleteId);
if (result.success) {
toast.success("Location deleted");
setDeleteId(null);
} else {
toast.error(result.error);
}
});
};
return (
<div className="space-y-4">
<PageHeader title="Locations" description="Manage your storage locations">
<Button
onClick={() => {
setEditLocation(undefined);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add Location
</Button>
</PageHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search locations..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DataTableViewOptions table={table} />
</div>
<DataTable table={table} emptyMessage="No locations found. Add your first location!" />
<DataTablePagination table={table} totalCount={totalCount} />
<LocationModal
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) setEditLocation(undefined);
}}
location={editLocation}
/>
<DeleteDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
title="Delete Location"
description="This will permanently delete this location. Items stored here will be unlinked."
onConfirm={handleDelete}
isLoading={isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { locationSchema } from "@/schemas/location.schema";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
export async function createLocation(input: unknown): Promise<ActionResult<{ id: string }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = locationSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
try {
const location = await prisma.location.create({
data: {
...parsed.data,
description: parsed.data.description || null,
userId: session.user.id,
},
});
revalidatePath("/locations");
return { success: true, data: { id: location.id } };
} catch {
return { success: false, error: "Failed to create location" };
}
}
export async function updateLocation(id: string, input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = locationSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
const existing = await prisma.location.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.location.update({
where: { id },
data: {
...parsed.data,
description: parsed.data.description || null,
},
});
revalidatePath("/locations");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update location" };
}
}
export async function deleteLocation(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.location.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.location.delete({ where: { id } });
revalidatePath("/locations");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete location" };
}
}
export async function archiveLocation(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.location.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.location.update({
where: { id },
data: { archived: !existing.archived },
});
revalidatePath("/locations");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to archive location" };
}
}

View File

@@ -0,0 +1,30 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function LocationsLoading() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-1 h-4 w-60" />
</div>
<Skeleton className="h-9 w-32" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-9 w-24" />
</div>
<div className="rounded-md border">
<div className="h-10 border-b bg-muted/50" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b px-4 py-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getLocations } from "@/data/location.queries";
import { LocationTable } from "./_components/location-table";
interface LocationsPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function LocationsPage({ searchParams }: LocationsPageProps) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const params = await searchParams;
const { data, pageCount, totalCount } = await getLocations(session.user.id, {
page: typeof params.page === "string" ? params.page : "1",
perPage: typeof params.perPage === "string" ? params.perPage : "20",
sort: typeof params.sort === "string" ? params.sort : undefined,
order: typeof params.order === "string" ? (params.order as "asc" | "desc") : undefined,
search: typeof params.search === "string" ? params.search : undefined,
});
return (
<LocationTable
data={JSON.parse(JSON.stringify(data))}
pageCount={pageCount}
totalCount={totalCount}
/>
);
}

View File

@@ -0,0 +1,190 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Archive, Trash2, Droplet } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { StatusBadge, getStockStatus } from "@/components/shared/status-badge";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface PaintRow {
id: string;
name: string;
brand: string;
line: string | null;
color: string;
colorHex: string;
finish: string;
volumeML: number;
usedML: number;
cost: number | null;
purchaseDate: Date | null;
notes: string | null;
archived: boolean;
vendor: { id: string; name: string } | null;
location: { id: string; name: string } | null;
}
interface PaintColumnsProps {
onEdit: (paint: PaintRow) => void;
onArchive: (id: string) => void;
onDelete: (id: string) => void;
onLogUsage: (paint: PaintRow) => void;
lowStockThreshold: number;
}
export function getPaintColumns({
onEdit,
onArchive,
onDelete,
onLogUsage,
lowStockThreshold,
}: PaintColumnsProps): ColumnDef<PaintRow, unknown>[] {
return [
{
id: "color",
header: "",
cell: ({ row }) => <ColorSwatch hex={row.original.colorHex} size="sm" />,
enableHiding: false,
size: 40,
},
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const remaining = row.original.volumeML - row.original.usedML;
const status = getStockStatus(
remaining,
row.original.volumeML,
lowStockThreshold,
row.original.archived
);
return (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
<StatusBadge variant={status} />
</div>
);
},
enableHiding: false,
},
{
accessorKey: "brand",
header: ({ column }) => <DataTableColumnHeader column={column} title="Brand" />,
cell: ({ row }) => (
<div className="flex flex-col">
<span className="text-sm">{row.original.brand}</span>
{row.original.line && (
<span className="text-xs text-muted-foreground">{row.original.line}</span>
)}
</div>
),
},
{
accessorKey: "finish",
header: ({ column }) => <DataTableColumnHeader column={column} title="Finish" />,
cell: ({ row }) => (
<Badge variant="secondary" className="text-xs">
{row.original.finish}
</Badge>
),
},
{
id: "remaining",
header: ({ column }) => <DataTableColumnHeader column={column} title="Remaining" />,
cell: ({ row }) => {
const remaining = row.original.volumeML - row.original.usedML;
const percent =
row.original.volumeML > 0
? (remaining / row.original.volumeML) * 100
: 0;
const isLow = percent <= lowStockThreshold;
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isLow ? "bg-orange-500" : "bg-primary"
}`}
style={{ width: `${Math.max(0, Math.min(100, percent))}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-14 text-right">
{remaining.toFixed(1)} ml
</span>
</div>
);
},
},
{
id: "location",
header: "Location",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.location?.name ?? "\u2014"}
</span>
),
},
{
id: "vendor",
header: "Vendor",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.vendor?.name ?? "\u2014"}
</span>
),
},
{
accessorKey: "cost",
header: ({ column }) => <DataTableColumnHeader column={column} title="Cost" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.cost != null ? `\u20AC${row.original.cost.toFixed(2)}` : "\u2014"}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onLogUsage(row.original)}>
<Droplet className="mr-2 h-3.5 w-3.5" />
Log Usage
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onArchive(row.original.id)}>
<Archive className="mr-2 h-3.5 w-3.5" />
{row.original.archived ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row.original.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,380 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { paintSchema, type PaintInput } from "@/schemas/paint.schema";
import { PAINT_FINISHES } from "@/lib/constants";
import { createPaint, updatePaint } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { CatalogBrowserButton } from "@/components/shared/catalog-browser-button";
import { AutocompleteInput } from "@/components/shared/autocomplete-input";
import type { CatalogItem } from "@/types/catalog.types";
interface PaintFormProps {
paint?: {
id: string;
name: string;
brand: string;
line: string | null;
color: string;
colorHex: string;
finish: string;
volumeML: number;
usedML: number;
cost: number | null;
purchaseDate: Date | null;
notes: string | null;
vendorId: string | null;
locationId: string | null;
};
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
onSuccess: () => void;
}
export function PaintForm({ paint, vendors, locations, onSuccess }: PaintFormProps) {
const [isPending, startTransition] = useTransition();
const isEditing = !!paint;
const form = useForm<PaintInput>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(paintSchema) as any,
defaultValues: {
name: paint?.name ?? "",
brand: paint?.brand ?? "",
line: paint?.line ?? "",
color: paint?.color ?? "",
colorHex: paint?.colorHex ?? "#000000",
finish: (paint?.finish as PaintInput["finish"]) ?? "Matte",
volumeML: paint?.volumeML ?? 17,
usedML: paint?.usedML ?? 0,
cost: paint?.cost ?? undefined,
purchaseDate: paint?.purchaseDate
? new Date(paint.purchaseDate).toISOString().split("T")[0]
: "",
notes: paint?.notes ?? "",
vendorId: paint?.vendorId ?? "",
locationId: paint?.locationId ?? "",
},
});
const watchColorHex = form.watch("colorHex");
function handleCatalogSelect(item: CatalogItem) {
form.setValue("name", item.name);
form.setValue("brand", item.brand);
if (item.color) form.setValue("color", item.color);
if (item.colorHex) form.setValue("colorHex", item.colorHex);
if (item.line) form.setValue("line", item.line);
if (item.finish) {
const match = PAINT_FINISHES.find(
(f) => f.toUpperCase() === item.finish!.toUpperCase(),
);
if (match) form.setValue("finish", match);
}
if (item.volume) form.setValue("volumeML", item.volume);
if (item.price != null) form.setValue("cost", item.price);
}
function onSubmit(values: PaintInput) {
startTransition(async () => {
const result = isEditing
? await updatePaint(paint!.id, values)
: await createPaint(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(isEditing ? "Paint updated" : "Paint created");
form.reset();
onSuccess();
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{!isEditing && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Auto-fill from product catalog
</p>
<CatalogBrowserButton type="paint" onSelect={handleCatalogSelect} />
</div>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Name</FormLabel>
<FormControl>
{!isEditing ? (
<AutocompleteInput
type="paint"
value={field.value}
onChange={field.onChange}
onSelectItem={handleCatalogSelect}
placeholder="Paint name — type to search catalog"
/>
) : (
<Input placeholder="Paint name" {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input placeholder="Brand name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="line"
render={({ field }) => (
<FormItem>
<FormLabel>Product Line</FormLabel>
<FormControl>
<Input placeholder="e.g. Base, Layer, Contrast" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="finish"
render={({ field }) => (
<FormItem>
<FormLabel>Finish</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select finish" />
</SelectTrigger>
</FormControl>
<SelectContent>
{PAINT_FINISHES.map((f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>Color Name</FormLabel>
<FormControl>
<Input placeholder="e.g. Retributor Armour" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="colorHex"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Color Hex</FormLabel>
<div className="flex items-center gap-2">
<FormControl>
<Input placeholder="#000000" {...field} className="flex-1" />
</FormControl>
<input
type="color"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
className="h-9 w-9 cursor-pointer rounded border border-border bg-transparent p-0.5"
/>
<ColorSwatch hex={watchColorHex || "#000000"} size="md" />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="volumeML"
render={({ field }) => (
<FormItem>
<FormLabel>Volume (ml)</FormLabel>
<FormControl>
<Input type="number" step="0.1" min="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="usedML"
render={({ field }) => (
<FormItem>
<FormLabel>Used (ml)</FormLabel>
<FormControl>
<Input type="number" step="0.1" min="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vendorId"
render={({ field }) => (
<FormItem>
<FormLabel>Vendor</FormLabel>
<Select
onValueChange={(val) => field.onChange(val === "none" ? "" : val)}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select vendor" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{vendors.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locationId"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<Select
onValueChange={(val) => field.onChange(val === "none" ? "" : val)}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select location" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{locations.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cost"
render={({ field }) => (
<FormItem>
<FormLabel>Cost</FormLabel>
<FormControl>
<Input type="number" step="0.01" min="0" placeholder="0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="purchaseDate"
render={({ field }) => (
<FormItem>
<FormLabel>Purchase Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea placeholder="Optional notes" rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { PaintForm } from "./paint-form";
interface PaintModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
paint?: Parameters<typeof PaintForm>[0]["paint"];
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
}
export function PaintModal({
open,
onOpenChange,
paint,
vendors,
locations,
}: PaintModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>{paint ? "Edit Paint" : "Add Paint"}</DialogTitle>
<DialogDescription>
{paint
? "Update the paint details below."
: "Add a new paint to your inventory."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-4">
<PaintForm
paint={paint}
vendors={vendors}
locations={locations}
onSuccess={() => onOpenChange(false)}
/>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,214 @@
"use client";
import { useState, useTransition, useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { toast } from "sonner";
import { useDataTable } from "@/hooks/use-data-table";
import { PAINT_FINISHES } from "@/lib/constants";
import { getPaintColumns, type PaintRow } from "./paint-columns";
import { PaintModal } from "./paint-modal";
import { deletePaint, archivePaint, logPaintUsage } from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
import { DataTableFacetedFilter } from "@/components/shared/data-table-faceted-filter";
import { DeleteDialog } from "@/components/shared/delete-dialog";
import { UsageLogDialog } from "@/components/shared/usage-log-dialog";
import { PageHeader } from "@/components/shared/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface PaintTableProps {
data: PaintRow[];
pageCount: number;
totalCount: number;
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
lowStockThreshold: number;
}
export function PaintTable({
data,
pageCount,
totalCount,
vendors,
locations,
lowStockThreshold,
}: PaintTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [editPaint, setEditPaint] = useState<PaintRow | undefined>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [usagePaint, setUsagePaint] = useState<PaintRow | null>(null);
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const finishFilter = new Set(searchParams.getAll("finish"));
const vendorFilter = new Set(searchParams.getAll("vendor"));
const locationFilter = new Set(searchParams.getAll("location"));
const updateFilters = useCallback(
(key: string, values: Set<string>) => {
const params = new URLSearchParams(searchParams.toString());
params.delete(key);
values.forEach((v) => params.append(key, v));
params.set("page", "1");
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
const updateSearch = (value: string) => {
setSearchValue(value);
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("search", value);
params.set("page", "1");
} else {
params.delete("search");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const columns = getPaintColumns({
onEdit: (paint) => {
setEditPaint(paint);
setModalOpen(true);
},
onArchive: (id) => {
startTransition(async () => {
const result = await archivePaint(id);
if (result.success) toast.success("Paint updated");
else toast.error(result.error);
});
},
onDelete: (id) => setDeleteId(id),
onLogUsage: (paint) => setUsagePaint(paint),
lowStockThreshold,
});
const { table } = useDataTable({ data, columns, pageCount });
const handleDelete = () => {
if (!deleteId) return;
startTransition(async () => {
const result = await deletePaint(deleteId);
if (result.success) {
toast.success("Paint deleted");
setDeleteId(null);
} else {
toast.error(result.error);
}
});
};
const finishOptions = PAINT_FINISHES.map((f) => ({ label: f, value: f }));
const vendorOptions = vendors.map((v) => ({ label: v.name, value: v.id }));
const locationOptions = locations.map((l) => ({ label: l.name, value: l.id }));
return (
<div className="space-y-4">
<PageHeader title="Paints" description="Manage your miniature paint collection">
<Button
onClick={() => {
setEditPaint(undefined);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add Paint
</Button>
</PageHeader>
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search paints..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DataTableFacetedFilter
title="Finish"
options={finishOptions}
selectedValues={finishFilter}
onSelectionChange={(values) => updateFilters("finish", values)}
/>
<DataTableFacetedFilter
title="Vendor"
options={vendorOptions}
selectedValues={vendorFilter}
onSelectionChange={(values) => updateFilters("vendor", values)}
/>
<DataTableFacetedFilter
title="Location"
options={locationOptions}
selectedValues={locationFilter}
onSelectionChange={(values) => updateFilters("location", values)}
/>
<DataTableViewOptions table={table} />
</div>
<DataTable table={table} emptyMessage="No paints found. Add your first paint!" />
<DataTablePagination table={table} totalCount={totalCount} />
<PaintModal
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) setEditPaint(undefined);
}}
paint={
editPaint
? {
id: editPaint.id,
name: editPaint.name,
brand: editPaint.brand,
line: editPaint.line,
color: editPaint.color,
colorHex: editPaint.colorHex,
finish: editPaint.finish,
volumeML: editPaint.volumeML,
usedML: editPaint.usedML,
cost: editPaint.cost,
purchaseDate: editPaint.purchaseDate,
notes: editPaint.notes,
vendorId: editPaint.vendor?.id ?? null,
locationId: editPaint.location?.id ?? null,
}
: undefined
}
vendors={vendors}
locations={locations}
/>
<DeleteDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
title="Delete Paint"
description="This will permanently delete this paint and all its usage logs."
onConfirm={handleDelete}
isLoading={isPending}
/>
{usagePaint && (
<UsageLogDialog
open={!!usagePaint}
onOpenChange={(open) => !open && setUsagePaint(null)}
itemName={usagePaint.name}
unit="ml"
onSubmit={async (amount, notes) => {
const result = await logPaintUsage(usagePaint.id, { amount, notes });
return result;
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,155 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { paintSchema } from "@/schemas/paint.schema";
import { usageLogSchema } from "@/schemas/usage-log.schema";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
export async function createPaint(input: unknown): Promise<ActionResult<{ id: string }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = paintSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
try {
const paint = await prisma.paint.create({
data: {
name: parsed.data.name,
brand: parsed.data.brand,
line: parsed.data.line || null,
color: parsed.data.color,
colorHex: parsed.data.colorHex,
finish: parsed.data.finish,
volumeML: parsed.data.volumeML,
usedML: parsed.data.usedML,
purchaseDate: parsed.data.purchaseDate ? new Date(parsed.data.purchaseDate) : null,
cost: parsed.data.cost ?? null,
notes: parsed.data.notes || null,
vendorId: parsed.data.vendorId || null,
locationId: parsed.data.locationId || null,
userId: session.user.id,
},
});
revalidatePath("/paints");
revalidatePath("/dashboard");
return { success: true, data: { id: paint.id } };
} catch {
return { success: false, error: "Failed to create paint" };
}
}
export async function updatePaint(id: string, input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = paintSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
const existing = await prisma.paint.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.paint.update({
where: { id },
data: {
name: parsed.data.name,
brand: parsed.data.brand,
line: parsed.data.line || null,
color: parsed.data.color,
colorHex: parsed.data.colorHex,
finish: parsed.data.finish,
volumeML: parsed.data.volumeML,
usedML: parsed.data.usedML,
purchaseDate: parsed.data.purchaseDate ? new Date(parsed.data.purchaseDate) : null,
cost: parsed.data.cost ?? null,
notes: parsed.data.notes || null,
vendorId: parsed.data.vendorId || null,
locationId: parsed.data.locationId || null,
},
});
revalidatePath("/paints");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update paint" };
}
}
export async function deletePaint(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.paint.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.paint.delete({ where: { id } });
revalidatePath("/paints");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete paint" };
}
}
export async function archivePaint(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.paint.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.paint.update({
where: { id },
data: { archived: !existing.archived },
});
revalidatePath("/paints");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to archive paint" };
}
}
export async function logPaintUsage(paintId: string, input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = usageLogSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
const existing = await prisma.paint.findFirst({ where: { id: paintId, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.$transaction([
prisma.usageLog.create({
data: {
itemType: "PAINT",
itemId: paintId,
paintId,
amount: parsed.data.amount,
unit: "ml",
notes: parsed.data.notes || null,
userId: session.user.id,
},
}),
prisma.paint.update({
where: { id: paintId },
data: { usedML: { increment: parsed.data.amount } },
}),
]);
revalidatePath("/paints");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to log usage" };
}
}

View File

@@ -0,0 +1,34 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function PaintsLoading() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="mt-1 h-4 w-56" />
</div>
<Skeleton className="h-9 w-28" />
</div>
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
</div>
<div className="rounded-md border">
<div className="h-10 border-b bg-muted/50" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b px-4 py-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getPaints } from "@/data/paint.queries";
import { getVendorOptions } from "@/data/vendor.queries";
import { getLocationOptions } from "@/data/location.queries";
import { getUserSettings } from "@/data/settings.queries";
import { PaintTable } from "./_components/paint-table";
interface PaintsPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function PaintsPage({ searchParams }: PaintsPageProps) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const params = await searchParams;
const [paintsResult, vendors, locations, settings] = await Promise.all([
getPaints(session.user.id, {
page: typeof params.page === "string" ? params.page : "1",
perPage: typeof params.perPage === "string" ? params.perPage : "20",
sort: typeof params.sort === "string" ? params.sort : undefined,
order: typeof params.order === "string" ? (params.order as "asc" | "desc") : undefined,
search: typeof params.search === "string" ? params.search : undefined,
finish: params.finish,
vendor: params.vendor,
location: params.location,
}),
getVendorOptions(session.user.id),
getLocationOptions(session.user.id),
getUserSettings(session.user.id),
]);
return (
<PaintTable
data={JSON.parse(JSON.stringify(paintsResult.data))}
pageCount={paintsResult.pageCount}
totalCount={paintsResult.totalCount}
vendors={vendors}
locations={locations}
lowStockThreshold={settings.lowStockThreshold}
/>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Archive, Trash2, FlaskConical } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { StatusBadge, getStockStatus } from "@/components/shared/status-badge";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface ResinRow {
id: string;
name: string;
brand: string;
resinType: string;
color: string;
colorHex: string;
bottleSize: number;
usedML: number;
cost: number | null;
purchaseDate: Date | null;
notes: string | null;
archived: boolean;
vendor: { id: string; name: string } | null;
location: { id: string; name: string } | null;
}
interface ResinColumnsProps {
onEdit: (resin: ResinRow) => void;
onArchive: (id: string) => void;
onDelete: (id: string) => void;
onLogUsage: (resin: ResinRow) => void;
lowStockThreshold: number;
}
export function getResinColumns({
onEdit,
onArchive,
onDelete,
onLogUsage,
lowStockThreshold,
}: ResinColumnsProps): ColumnDef<ResinRow, unknown>[] {
return [
{
id: "color",
header: "",
cell: ({ row }) => <ColorSwatch hex={row.original.colorHex} size="sm" />,
enableHiding: false,
size: 40,
},
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const remaining = row.original.bottleSize - row.original.usedML;
const status = getStockStatus(
remaining,
row.original.bottleSize,
lowStockThreshold,
row.original.archived
);
return (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
<StatusBadge variant={status} />
</div>
);
},
enableHiding: false,
},
{
accessorKey: "brand",
header: ({ column }) => <DataTableColumnHeader column={column} title="Brand" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.brand}</span>
),
},
{
accessorKey: "resinType",
header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />,
cell: ({ row }) => (
<Badge variant="secondary" className="text-xs">
{row.original.resinType}
</Badge>
),
},
{
id: "remaining",
header: ({ column }) => <DataTableColumnHeader column={column} title="Remaining" />,
cell: ({ row }) => {
const remaining = row.original.bottleSize - row.original.usedML;
const percent =
row.original.bottleSize > 0
? (remaining / row.original.bottleSize) * 100
: 0;
const isLow = percent <= lowStockThreshold;
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isLow ? "bg-orange-500" : "bg-primary"
}`}
style={{ width: `${Math.max(0, Math.min(100, percent))}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-16 text-right">
{remaining.toFixed(0)} ml
</span>
</div>
);
},
},
{
id: "location",
header: "Location",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.location?.name ?? "\u2014"}
</span>
),
},
{
id: "vendor",
header: "Vendor",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.vendor?.name ?? "\u2014"}
</span>
),
},
{
accessorKey: "cost",
header: ({ column }) => <DataTableColumnHeader column={column} title="Cost" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.cost != null ? `\u20AC${row.original.cost.toFixed(2)}` : "\u2014"}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onLogUsage(row.original)}>
<FlaskConical className="mr-2 h-3.5 w-3.5" />
Log Usage
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onArchive(row.original.id)}>
<Archive className="mr-2 h-3.5 w-3.5" />
{row.original.archived ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row.original.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,363 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { resinSchema, type ResinInput } from "@/schemas/resin.schema";
import { RESIN_TYPES } from "@/lib/constants";
import { createResin, updateResin } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { CatalogBrowserButton } from "@/components/shared/catalog-browser-button";
import { AutocompleteInput } from "@/components/shared/autocomplete-input";
import type { CatalogItem } from "@/types/catalog.types";
interface ResinFormProps {
resin?: {
id: string;
name: string;
brand: string;
resinType: string;
color: string;
colorHex: string;
bottleSize: number;
usedML: number;
cost: number | null;
purchaseDate: Date | null;
notes: string | null;
vendorId: string | null;
locationId: string | null;
};
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
onSuccess: () => void;
}
export function ResinForm({ resin, vendors, locations, onSuccess }: ResinFormProps) {
const [isPending, startTransition] = useTransition();
const isEditing = !!resin;
const form = useForm<ResinInput>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(resinSchema) as any,
defaultValues: {
name: resin?.name ?? "",
brand: resin?.brand ?? "",
resinType: (resin?.resinType as ResinInput["resinType"]) ?? "Standard",
color: resin?.color ?? "",
colorHex: resin?.colorHex ?? "#000000",
bottleSize: resin?.bottleSize ?? 500,
usedML: resin?.usedML ?? 0,
cost: resin?.cost ?? undefined,
purchaseDate: resin?.purchaseDate
? new Date(resin.purchaseDate).toISOString().split("T")[0]
: "",
notes: resin?.notes ?? "",
vendorId: resin?.vendorId ?? "",
locationId: resin?.locationId ?? "",
},
});
const watchColorHex = form.watch("colorHex");
function handleCatalogSelect(item: CatalogItem) {
form.setValue("name", item.name);
form.setValue("brand", item.brand);
if (item.color) form.setValue("color", item.color);
if (item.colorHex) form.setValue("colorHex", item.colorHex);
if (item.resinType) {
const match = RESIN_TYPES.find(
(t) => t.toUpperCase() === item.resinType!.toUpperCase(),
);
if (match) form.setValue("resinType", match);
}
if (item.volume) form.setValue("bottleSize", item.volume);
if (item.price != null) form.setValue("cost", item.price);
}
function onSubmit(values: ResinInput) {
startTransition(async () => {
const result = isEditing
? await updateResin(resin!.id, values)
: await createResin(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(isEditing ? "Resin updated" : "Resin created");
form.reset();
onSuccess();
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{!isEditing && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Auto-fill from product catalog
</p>
<CatalogBrowserButton type="resin" onSelect={handleCatalogSelect} />
</div>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Name</FormLabel>
<FormControl>
{!isEditing ? (
<AutocompleteInput
type="resin"
value={field.value}
onChange={field.onChange}
onSelectItem={handleCatalogSelect}
placeholder="Resin name — type to search catalog"
/>
) : (
<Input placeholder="Resin name" {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input placeholder="Brand name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="resinType"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{RESIN_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>Color Name</FormLabel>
<FormControl>
<Input placeholder="e.g. Clear Grey" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="colorHex"
render={({ field }) => (
<FormItem>
<FormLabel>Color Hex</FormLabel>
<div className="flex items-center gap-2">
<FormControl>
<Input placeholder="#000000" {...field} className="flex-1" />
</FormControl>
<input
type="color"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
className="h-9 w-9 cursor-pointer rounded border border-border bg-transparent p-0.5"
/>
<ColorSwatch hex={watchColorHex || "#000000"} size="md" />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bottleSize"
render={({ field }) => (
<FormItem>
<FormLabel>Bottle Size (ml)</FormLabel>
<FormControl>
<Input type="number" step="1" min="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="usedML"
render={({ field }) => (
<FormItem>
<FormLabel>Used (ml)</FormLabel>
<FormControl>
<Input type="number" step="0.1" min="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vendorId"
render={({ field }) => (
<FormItem>
<FormLabel>Vendor</FormLabel>
<Select
onValueChange={(val) => field.onChange(val === "none" ? "" : val)}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select vendor" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{vendors.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locationId"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<Select
onValueChange={(val) => field.onChange(val === "none" ? "" : val)}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select location" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{locations.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cost"
render={({ field }) => (
<FormItem>
<FormLabel>Cost</FormLabel>
<FormControl>
<Input type="number" step="0.01" min="0" placeholder="0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="purchaseDate"
render={({ field }) => (
<FormItem>
<FormLabel>Purchase Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea placeholder="Optional notes" rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ResinForm } from "./resin-form";
interface ResinModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
resin?: Parameters<typeof ResinForm>[0]["resin"];
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
}
export function ResinModal({
open,
onOpenChange,
resin,
vendors,
locations,
}: ResinModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>{resin ? "Edit Resin" : "Add Resin"}</DialogTitle>
<DialogDescription>
{resin
? "Update the resin details below."
: "Add a new resin bottle to your inventory."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-4">
<ResinForm
resin={resin}
vendors={vendors}
locations={locations}
onSuccess={() => onOpenChange(false)}
/>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,213 @@
"use client";
import { useState, useTransition, useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { toast } from "sonner";
import { useDataTable } from "@/hooks/use-data-table";
import { RESIN_TYPES } from "@/lib/constants";
import { getResinColumns, type ResinRow } from "./resin-columns";
import { ResinModal } from "./resin-modal";
import { deleteResin, archiveResin, logResinUsage } from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
import { DataTableFacetedFilter } from "@/components/shared/data-table-faceted-filter";
import { DeleteDialog } from "@/components/shared/delete-dialog";
import { UsageLogDialog } from "@/components/shared/usage-log-dialog";
import { PageHeader } from "@/components/shared/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface ResinTableProps {
data: ResinRow[];
pageCount: number;
totalCount: number;
vendors: { id: string; name: string }[];
locations: { id: string; name: string }[];
lowStockThreshold: number;
}
export function ResinTable({
data,
pageCount,
totalCount,
vendors,
locations,
lowStockThreshold,
}: ResinTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [editResin, setEditResin] = useState<ResinRow | undefined>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [usageResin, setUsageResin] = useState<ResinRow | null>(null);
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const resinTypeFilter = new Set(searchParams.getAll("resinType"));
const vendorFilter = new Set(searchParams.getAll("vendor"));
const locationFilter = new Set(searchParams.getAll("location"));
const updateFilters = useCallback(
(key: string, values: Set<string>) => {
const params = new URLSearchParams(searchParams.toString());
params.delete(key);
values.forEach((v) => params.append(key, v));
params.set("page", "1");
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
const updateSearch = (value: string) => {
setSearchValue(value);
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("search", value);
params.set("page", "1");
} else {
params.delete("search");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const columns = getResinColumns({
onEdit: (resin) => {
setEditResin(resin);
setModalOpen(true);
},
onArchive: (id) => {
startTransition(async () => {
const result = await archiveResin(id);
if (result.success) toast.success("Resin updated");
else toast.error(result.error);
});
},
onDelete: (id) => setDeleteId(id),
onLogUsage: (resin) => setUsageResin(resin),
lowStockThreshold,
});
const { table } = useDataTable({ data, columns, pageCount });
const handleDelete = () => {
if (!deleteId) return;
startTransition(async () => {
const result = await deleteResin(deleteId);
if (result.success) {
toast.success("Resin deleted");
setDeleteId(null);
} else {
toast.error(result.error);
}
});
};
const resinTypeOptions = RESIN_TYPES.map((t) => ({ label: t, value: t }));
const vendorOptions = vendors.map((v) => ({ label: v.name, value: v.id }));
const locationOptions = locations.map((l) => ({ label: l.name, value: l.id }));
return (
<div className="space-y-4">
<PageHeader title="Resins" description="Manage your SLA resin inventory">
<Button
onClick={() => {
setEditResin(undefined);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add Resin
</Button>
</PageHeader>
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search resins..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DataTableFacetedFilter
title="Type"
options={resinTypeOptions}
selectedValues={resinTypeFilter}
onSelectionChange={(values) => updateFilters("resinType", values)}
/>
<DataTableFacetedFilter
title="Vendor"
options={vendorOptions}
selectedValues={vendorFilter}
onSelectionChange={(values) => updateFilters("vendor", values)}
/>
<DataTableFacetedFilter
title="Location"
options={locationOptions}
selectedValues={locationFilter}
onSelectionChange={(values) => updateFilters("location", values)}
/>
<DataTableViewOptions table={table} />
</div>
<DataTable table={table} emptyMessage="No resins found. Add your first bottle!" />
<DataTablePagination table={table} totalCount={totalCount} />
<ResinModal
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) setEditResin(undefined);
}}
resin={
editResin
? {
id: editResin.id,
name: editResin.name,
brand: editResin.brand,
resinType: editResin.resinType,
color: editResin.color,
colorHex: editResin.colorHex,
bottleSize: editResin.bottleSize,
usedML: editResin.usedML,
cost: editResin.cost,
purchaseDate: editResin.purchaseDate,
notes: editResin.notes,
vendorId: editResin.vendor?.id ?? null,
locationId: editResin.location?.id ?? null,
}
: undefined
}
vendors={vendors}
locations={locations}
/>
<DeleteDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
title="Delete Resin"
description="This will permanently delete this resin bottle and all its usage logs."
onConfirm={handleDelete}
isLoading={isPending}
/>
{usageResin && (
<UsageLogDialog
open={!!usageResin}
onOpenChange={(open) => !open && setUsageResin(null)}
itemName={usageResin.name}
unit="ml"
onSubmit={async (amount, notes) => {
const result = await logResinUsage(usageResin.id, { amount, notes });
return result;
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { resinSchema } from "@/schemas/resin.schema";
import { usageLogSchema } from "@/schemas/usage-log.schema";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
export async function createResin(input: unknown): Promise<ActionResult<{ id: string }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = resinSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
try {
const resin = await prisma.resin.create({
data: {
name: parsed.data.name,
brand: parsed.data.brand,
resinType: parsed.data.resinType,
color: parsed.data.color,
colorHex: parsed.data.colorHex,
bottleSize: parsed.data.bottleSize,
usedML: parsed.data.usedML,
purchaseDate: parsed.data.purchaseDate ? new Date(parsed.data.purchaseDate) : null,
cost: parsed.data.cost ?? null,
notes: parsed.data.notes || null,
vendorId: parsed.data.vendorId || null,
locationId: parsed.data.locationId || null,
userId: session.user.id,
},
});
revalidatePath("/resins");
revalidatePath("/dashboard");
return { success: true, data: { id: resin.id } };
} catch {
return { success: false, error: "Failed to create resin" };
}
}
export async function updateResin(id: string, input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = resinSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
const existing = await prisma.resin.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.resin.update({
where: { id },
data: {
name: parsed.data.name,
brand: parsed.data.brand,
resinType: parsed.data.resinType,
color: parsed.data.color,
colorHex: parsed.data.colorHex,
bottleSize: parsed.data.bottleSize,
usedML: parsed.data.usedML,
purchaseDate: parsed.data.purchaseDate ? new Date(parsed.data.purchaseDate) : null,
cost: parsed.data.cost ?? null,
notes: parsed.data.notes || null,
vendorId: parsed.data.vendorId || null,
locationId: parsed.data.locationId || null,
},
});
revalidatePath("/resins");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update resin" };
}
}
export async function deleteResin(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.resin.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.resin.delete({ where: { id } });
revalidatePath("/resins");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete resin" };
}
}
export async function archiveResin(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.resin.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.resin.update({
where: { id },
data: { archived: !existing.archived },
});
revalidatePath("/resins");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to archive resin" };
}
}
export async function logResinUsage(resinId: string, input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = usageLogSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
const existing = await prisma.resin.findFirst({ where: { id: resinId, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.$transaction([
prisma.usageLog.create({
data: {
itemType: "RESIN",
itemId: resinId,
resinId,
amount: parsed.data.amount,
unit: "ml",
notes: parsed.data.notes || null,
userId: session.user.id,
},
}),
prisma.resin.update({
where: { id: resinId },
data: { usedML: { increment: parsed.data.amount } },
}),
]);
revalidatePath("/resins");
revalidatePath("/dashboard");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to log usage" };
}
}

View File

@@ -0,0 +1,34 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ResinsLoading() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="mt-1 h-4 w-56" />
</div>
<Skeleton className="h-9 w-28" />
</div>
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
</div>
<div className="rounded-md border">
<div className="h-10 border-b bg-muted/50" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b px-4 py-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getResins } from "@/data/resin.queries";
import { getVendorOptions } from "@/data/vendor.queries";
import { getLocationOptions } from "@/data/location.queries";
import { getUserSettings } from "@/data/settings.queries";
import { ResinTable } from "./_components/resin-table";
interface ResinsPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function ResinsPage({ searchParams }: ResinsPageProps) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const params = await searchParams;
const [resinsResult, vendors, locations, settings] = await Promise.all([
getResins(session.user.id, {
page: typeof params.page === "string" ? params.page : "1",
perPage: typeof params.perPage === "string" ? params.perPage : "20",
sort: typeof params.sort === "string" ? params.sort : undefined,
order: typeof params.order === "string" ? (params.order as "asc" | "desc") : undefined,
search: typeof params.search === "string" ? params.search : undefined,
resinType: params.resinType,
vendor: params.vendor,
location: params.location,
}),
getVendorOptions(session.user.id),
getLocationOptions(session.user.id),
getUserSettings(session.user.id),
]);
return (
<ResinTable
data={JSON.parse(JSON.stringify(resinsResult.data))}
pageCount={resinsResult.pageCount}
totalCount={resinsResult.totalCount}
vendors={vendors}
locations={locations}
lowStockThreshold={settings.lowStockThreshold}
/>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { useTheme } from "next-themes";
import { settingsSchema, type SettingsInput } from "@/schemas/settings.schema";
import { CURRENCIES, UNITS } from "@/lib/constants";
import { updateSettings } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
interface SettingsFormProps {
settings: {
lowStockThreshold: number;
currency: string;
theme: string;
units: string;
};
}
export function SettingsForm({ settings }: SettingsFormProps) {
const [isPending, startTransition] = useTransition();
const { setTheme } = useTheme();
const form = useForm<SettingsInput>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(settingsSchema) as any,
defaultValues: {
lowStockThreshold: settings.lowStockThreshold,
currency: settings.currency as SettingsInput["currency"],
theme: settings.theme as SettingsInput["theme"],
units: settings.units as SettingsInput["units"],
},
});
function onSubmit(values: SettingsInput) {
startTransition(async () => {
const result = await updateSettings(values);
if (!result.success) {
toast.error(result.error);
return;
}
setTheme(values.theme);
toast.success("Settings updated");
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Inventory Settings</CardTitle>
<CardDescription>
Configure stock thresholds and display preferences.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="lowStockThreshold"
render={({ field }) => (
<FormItem>
<FormLabel>Low Stock Threshold (%)</FormLabel>
<FormControl>
<Input type="number" min="0" max="100" step="1" {...field} />
</FormControl>
<FormDescription>
Items with remaining stock below this percentage will be flagged as
low stock.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Currency</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Display currency for cost values.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="units"
render={({ field }) => (
<FormItem>
<FormLabel>Unit System</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{UNITS.map((u) => (
<SelectItem key={u} value={u}>
{u.charAt(0).toUpperCase() + u.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Measurement system for weight and volume.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Separator />
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize the look and feel.</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose your preferred color theme.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save Settings"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,43 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { settingsSchema } from "@/schemas/settings.schema";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
export async function updateSettings(input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = settingsSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
try {
await prisma.userSettings.upsert({
where: { userId: session.user.id },
update: {
lowStockThreshold: parsed.data.lowStockThreshold,
currency: parsed.data.currency,
theme: parsed.data.theme,
units: parsed.data.units,
},
create: {
userId: session.user.id,
lowStockThreshold: parsed.data.lowStockThreshold,
currency: parsed.data.currency,
theme: parsed.data.theme,
units: parsed.data.units,
},
});
revalidatePath("/settings");
revalidatePath("/dashboard");
revalidatePath("/filaments");
revalidatePath("/resins");
revalidatePath("/paints");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update settings" };
}
}

View File

@@ -0,0 +1,24 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function SettingsLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="mt-1 h-4 w-64" />
</div>
<div className="max-w-2xl space-y-6">
<div className="rounded-lg border p-6 space-y-4">
<Skeleton className="h-6 w-40" />
<div className="space-y-3">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
<Skeleton className="h-9 w-24" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getUserSettings } from "@/data/settings.queries";
import { PageHeader } from "@/components/shared/page-header";
import { SettingsForm } from "./_components/settings-form";
export default async function SettingsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const settings = await getUserSettings(session.user.id);
return (
<div className="space-y-6">
<PageHeader
title="Settings"
description="Manage your application preferences"
/>
<div className="max-w-2xl">
<SettingsForm
settings={JSON.parse(JSON.stringify(settings))}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Archive, Trash2, ExternalLink } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { StatusBadge } from "@/components/shared/status-badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface VendorRow {
id: string;
name: string;
website: string | null;
notes: string | null;
archived: boolean;
createdAt: Date;
_count: { filaments: number; resins: number; paints: number };
}
interface VendorColumnsProps {
onEdit: (vendor: VendorRow) => void;
onArchive: (id: string) => void;
onDelete: (id: string) => void;
}
export function getVendorColumns({
onEdit,
onArchive,
onDelete,
}: VendorColumnsProps): ColumnDef<VendorRow, unknown>[] {
return [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.archived && <StatusBadge variant="archived" />}
</div>
),
enableHiding: false,
},
{
accessorKey: "website",
header: ({ column }) => <DataTableColumnHeader column={column} title="Website" />,
cell: ({ row }) =>
row.original.website ? (
<a
href={row.original.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-primary hover:underline"
>
{new URL(row.original.website).hostname}
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: "items",
header: "Items",
cell: ({ row }) => {
const c = row.original._count;
return (
<span className="text-sm text-muted-foreground">
{c.filaments + c.resins + c.paints}
</span>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onArchive(row.original.id)}>
<Archive className="mr-2 h-3.5 w-3.5" />
{row.original.archived ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row.original.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { vendorSchema, type VendorInput } from "@/schemas/vendor.schema";
import { createVendor, updateVendor } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
interface VendorFormProps {
vendor?: { id: string; name: string; website: string | null; notes: string | null };
onSuccess: () => void;
}
export function VendorForm({ vendor, onSuccess }: VendorFormProps) {
const [isPending, startTransition] = useTransition();
const isEditing = !!vendor;
const form = useForm<VendorInput>({
resolver: zodResolver(vendorSchema),
defaultValues: {
name: vendor?.name ?? "",
website: vendor?.website ?? "",
notes: vendor?.notes ?? "",
},
});
function onSubmit(values: VendorInput) {
startTransition(async () => {
const result = isEditing
? await updateVendor(vendor!.id, values)
: await createVendor(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(isEditing ? "Vendor updated" : "Vendor created");
form.reset();
onSuccess();
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Vendor name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>
<FormControl>
<Input placeholder="https://example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea placeholder="Optional notes" rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { VendorForm } from "./vendor-form";
interface VendorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
vendor?: { id: string; name: string; website: string | null; notes: string | null };
}
export function VendorModal({ open, onOpenChange, vendor }: VendorModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{vendor ? "Edit Vendor" : "Add Vendor"}</DialogTitle>
<DialogDescription>
{vendor ? "Update the vendor details below." : "Add a new vendor to your inventory."}
</DialogDescription>
</DialogHeader>
<VendorForm vendor={vendor} onSuccess={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { toast } from "sonner";
import { useDataTable } from "@/hooks/use-data-table";
import { useDebounce } from "@/hooks/use-debounce";
import { getVendorColumns } from "./vendor-columns";
import { VendorModal } from "./vendor-modal";
import { deleteVendor, archiveVendor } from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
import { DeleteDialog } from "@/components/shared/delete-dialog";
import { PageHeader } from "@/components/shared/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface VendorRow {
id: string;
name: string;
website: string | null;
notes: string | null;
archived: boolean;
createdAt: Date;
_count: { filaments: number; resins: number; paints: number };
}
interface VendorTableProps {
data: VendorRow[];
pageCount: number;
totalCount: number;
}
export function VendorTable({ data, pageCount, totalCount }: VendorTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [editVendor, setEditVendor] = useState<VendorRow | undefined>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const debouncedSearch = useDebounce(searchValue, 300);
// Update URL when search changes
const updateSearch = (value: string) => {
setSearchValue(value);
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("search", value);
params.set("page", "1");
} else {
params.delete("search");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const columns = getVendorColumns({
onEdit: (vendor) => {
setEditVendor(vendor);
setModalOpen(true);
},
onArchive: (id) => {
startTransition(async () => {
const result = await archiveVendor(id);
if (result.success) {
toast.success("Vendor updated");
} else {
toast.error(result.error);
}
});
},
onDelete: (id) => setDeleteId(id),
});
const { table } = useDataTable({ data, columns, pageCount });
const handleDelete = () => {
if (!deleteId) return;
startTransition(async () => {
const result = await deleteVendor(deleteId);
if (result.success) {
toast.success("Vendor deleted");
setDeleteId(null);
} else {
toast.error(result.error);
}
});
};
return (
<div className="space-y-4">
<PageHeader title="Vendors" description="Manage your inventory vendors">
<Button onClick={() => { setEditVendor(undefined); setModalOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
Add Vendor
</Button>
</PageHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search vendors..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DataTableViewOptions table={table} />
</div>
<DataTable table={table} emptyMessage="No vendors found. Add your first vendor!" />
<DataTablePagination table={table} totalCount={totalCount} />
<VendorModal
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) setEditVendor(undefined);
}}
vendor={editVendor}
/>
<DeleteDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
title="Delete Vendor"
description="This will permanently delete this vendor. Items linked to this vendor will be unlinked."
onConfirm={handleDelete}
isLoading={isPending}
/>
</div>
);
}

97
src/app/(app)/vendors/actions.ts vendored Normal file
View File

@@ -0,0 +1,97 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { vendorSchema } from "@/schemas/vendor.schema";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
export async function createVendor(input: unknown): Promise<ActionResult<{ id: string }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = vendorSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
try {
const vendor = await prisma.vendor.create({
data: {
...parsed.data,
website: parsed.data.website || null,
notes: parsed.data.notes || null,
userId: session.user.id,
},
});
revalidatePath("/vendors");
return { success: true, data: { id: vendor.id } };
} catch {
return { success: false, error: "Failed to create vendor" };
}
}
export async function updateVendor(id: string, input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = vendorSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
const existing = await prisma.vendor.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.vendor.update({
where: { id },
data: {
...parsed.data,
website: parsed.data.website || null,
notes: parsed.data.notes || null,
},
});
revalidatePath("/vendors");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update vendor" };
}
}
export async function deleteVendor(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.vendor.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.vendor.delete({ where: { id } });
revalidatePath("/vendors");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete vendor" };
}
}
export async function archiveVendor(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.vendor.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.vendor.update({
where: { id },
data: { archived: !existing.archived },
});
revalidatePath("/vendors");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to archive vendor" };
}
}

18
src/app/(app)/vendors/loading.tsx vendored Normal file
View File

@@ -0,0 +1,18 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function VendorsLoading() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-9 w-28" />
</div>
<Skeleton className="h-10 w-full" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
);
}

19
src/app/(app)/vendors/page.tsx vendored Normal file
View File

@@ -0,0 +1,19 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getVendors } from "@/data/vendor.queries";
import type { DataTableSearchParams } from "@/types/table.types";
import { VendorTable } from "./_components/vendor-table";
interface Props {
searchParams: Promise<DataTableSearchParams>;
}
export default async function VendorsPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const params = await searchParams;
const { data, pageCount, totalCount } = await getVendors(session.user.id, params);
return <VendorTable data={data} pageCount={pageCount} totalCount={totalCount} />;
}

View File

@@ -0,0 +1,7 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-md space-y-6 px-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use server";
import { signIn } from "@/lib/auth";
import { loginSchema } from "@/schemas/auth.schema";
import { AuthError } from "next-auth";
export async function loginAction(values: { email: string; password: string }) {
const parsed = loginSchema.safeParse(values);
if (!parsed.success) {
return { error: "Invalid email or password" };
}
try {
await signIn("credentials", {
email: parsed.data.email,
password: parsed.data.password,
redirect: false,
});
return { success: true };
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return { error: "Invalid email or password" };
default:
return { error: "Something went wrong" };
}
}
// This is a redirect error thrown by next-auth on success - rethrow it
throw error;
}
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Flame } from "lucide-react";
import { loginSchema, type LoginInput } from "@/schemas/auth.schema";
import { loginAction } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { APP_NAME } from "@/lib/constants";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema) as any,
defaultValues: { email: "", password: "" },
});
function onSubmit(values: LoginInput) {
setError(null);
startTransition(async () => {
try {
const result = await loginAction(values);
if (result?.error) {
setError(result.error);
return;
}
router.push(callbackUrl);
router.refresh();
} catch {
// Redirect from server action — Next.js handles this automatically
router.push(callbackUrl);
router.refresh();
}
});
}
return (
<>
<div className="flex flex-col items-center gap-2 text-center">
<Flame className="h-10 w-10 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">{APP_NAME}</h1>
<p className="text-sm text-muted-foreground">Sign in to manage your inventory</p>
</div>
<Card>
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="admin@dragonsstash.local"
autoComplete="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Signing in..." : "Sign In"}
</Button>
</form>
</Form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary underline-offset-4 hover:underline">
Register
</Link>
</p>
</CardContent>
</Card>
</>
);
}

View File

@@ -0,0 +1,42 @@
"use server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { registerSchema } from "@/schemas/auth.schema";
import type { ActionResult } from "@/types/api.types";
export async function registerUser(input: unknown): Promise<ActionResult<{ id: string }>> {
const parsed = registerSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
const existing = await prisma.user.findUnique({
where: { email: parsed.data.email },
});
if (existing) {
return { success: false, error: "An account with this email already exists" };
}
const hashedPassword = await bcrypt.hash(parsed.data.password, 10);
const user = await prisma.user.create({
data: {
name: parsed.data.name,
email: parsed.data.email,
hashedPassword,
role: "USER",
settings: {
create: {
lowStockThreshold: 10,
currency: "USD",
theme: "dark",
units: "metric",
},
},
},
});
return { success: true, data: { id: user.id } };
}

View File

@@ -0,0 +1,176 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Flame } from "lucide-react";
import { registerSchema, type RegisterInput } from "@/schemas/auth.schema";
import { registerUser } from "./actions";
import { loginAction } from "../login/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { APP_NAME } from "@/lib/constants";
export default function RegisterPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const form = useForm<RegisterInput>({
resolver: zodResolver(registerSchema) as any,
defaultValues: { name: "", email: "", password: "", confirmPassword: "" },
});
function onSubmit(values: RegisterInput) {
setError(null);
startTransition(async () => {
const result = await registerUser(values);
if (!result.success) {
setError(result.error);
return;
}
// Auto-login after registration using server action
try {
const loginResult = await loginAction({
email: values.email,
password: values.password,
});
if (loginResult?.error) {
setError("Account created but sign in failed. Please try logging in.");
return;
}
router.push("/dashboard");
router.refresh();
} catch {
// Redirect from server action
router.push("/dashboard");
router.refresh();
}
});
}
return (
<>
<div className="flex flex-col items-center gap-2 text-center">
<Flame className="h-10 w-10 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">{APP_NAME}</h1>
<p className="text-sm text-muted-foreground">Create an account to get started</p>
</div>
<Card>
<CardHeader>
<CardTitle>Create Account</CardTitle>
<CardDescription>Fill in your details below</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@example.com"
autoComplete="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="At least 6 characters"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Repeat your password"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Creating account..." : "Create Account"}
</Button>
</form>
</Form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import type { CatalogBrand, CatalogResponse } from "@/types/catalog.types";
import { fetchFilaments } from "@/lib/catalog/shopify";
import { deduplicateItems } from "@/lib/catalog/cache";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const brandFilter = searchParams.get("brand")?.toLowerCase();
const search = searchParams.get("search")?.toLowerCase();
try {
let items = deduplicateItems(await fetchFilaments());
// Build brand summary from unfiltered data
const brandMap = new Map<string, number>();
for (const p of items) {
brandMap.set(p.brand, (brandMap.get(p.brand) ?? 0) + 1);
}
if (brandFilter) {
items = items.filter((p) => p.brand.toLowerCase() === brandFilter);
}
if (search) {
items = items.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.brand.toLowerCase().includes(search) ||
(p.color && p.color.toLowerCase().includes(search)) ||
(p.material && p.material.toLowerCase().includes(search)),
);
}
const brands: CatalogBrand[] = Array.from(brandMap.entries())
.map(([name, count]) => ({
id: name.toLowerCase().replace(/[^a-z0-9]/g, "_"),
name,
type: "filament" as const,
itemCount: count,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const response: CatalogResponse = { items, brands };
return NextResponse.json(response);
} catch (error) {
console.error("Failed to fetch filament catalog:", error);
return NextResponse.json(
{ items: [], brands: [], error: "Failed to fetch filament data" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import type { CatalogBrand, CatalogItem, CatalogResponse } from "@/types/catalog.types";
// Static import — bundled at build time from the generated JSON
import paintsData from "@/data/catalog/paints.json";
const allPaints = paintsData as CatalogItem[];
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const brandFilter = searchParams.get("brand")?.toLowerCase();
const search = searchParams.get("search")?.toLowerCase();
let items = allPaints;
if (brandFilter) {
items = items.filter((p) => p.brand.toLowerCase() === brandFilter);
}
if (search) {
items = items.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.brand.toLowerCase().includes(search) ||
(p.line && p.line.toLowerCase().includes(search)) ||
(p.productCode && p.productCode.toLowerCase().includes(search)),
);
}
// Build brand summary from the FULL dataset (not filtered)
const brandMap = new Map<string, number>();
for (const p of allPaints) {
brandMap.set(p.brand, (brandMap.get(p.brand) ?? 0) + 1);
}
const brands: CatalogBrand[] = Array.from(brandMap.entries())
.map(([name, count]) => ({
id: name.toLowerCase().replace(/[^a-z0-9]/g, "_"),
name,
type: "paint" as const,
itemCount: count,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const response: CatalogResponse = { items, brands };
return NextResponse.json(response);
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import type { CatalogBrand, CatalogResponse } from "@/types/catalog.types";
import { fetchResins } from "@/lib/catalog/shopify";
import { deduplicateItems } from "@/lib/catalog/cache";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const brandFilter = searchParams.get("brand")?.toLowerCase();
const search = searchParams.get("search")?.toLowerCase();
try {
let items = deduplicateItems(await fetchResins());
// Build brand summary from unfiltered data
const brandMap = new Map<string, number>();
for (const p of items) {
brandMap.set(p.brand, (brandMap.get(p.brand) ?? 0) + 1);
}
if (brandFilter) {
items = items.filter((p) => p.brand.toLowerCase() === brandFilter);
}
if (search) {
items = items.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.brand.toLowerCase().includes(search) ||
(p.color && p.color.toLowerCase().includes(search)) ||
(p.resinType && p.resinType.toLowerCase().includes(search)),
);
}
const brands: CatalogBrand[] = Array.from(brandMap.entries())
.map(([name, count]) => ({
id: name.toLowerCase().replace(/[^a-z0-9]/g, "_"),
name,
type: "resin" as const,
itemCount: count,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const response: CatalogResponse = { items, brands };
return NextResponse.json(response);
} catch (error) {
console.error("Failed to fetch resin catalog:", error);
return NextResponse.json(
{ items: [], brands: [], error: "Failed to fetch resin data" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,27 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json(
{
status: "healthy",
timestamp: new Date().toISOString(),
database: "connected",
},
{ status: 200 }
);
} catch {
return NextResponse.json(
{
status: "unhealthy",
timestamp: new Date().toISOString(),
database: "disconnected",
},
{ status: 503 }
);
}
}

19
src/app/error.tsx Normal file
View File

@@ -0,0 +1,19 @@
"use client";
import { Button } from "@/components/ui/button";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}>Try again</Button>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

139
src/app/globals.css Normal file
View File

@@ -0,0 +1,139 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
/* Dragon's Stash Dark Theme (default) */
:root {
--radius: 0.5rem;
/* Background: #0f0f12 */
--background: oklch(0.13 0.005 285);
--foreground: oklch(0.93 0 0);
/* Card: #17171c */
--card: oklch(0.17 0.005 285);
--card-foreground: oklch(0.93 0 0);
/* Popover matches card */
--popover: oklch(0.17 0.005 285);
--popover-foreground: oklch(0.93 0 0);
/* Primary: #f97316 (orange) */
--primary: oklch(0.702 0.183 54.13);
--primary-foreground: oklch(1 0 0);
/* Secondary */
--secondary: oklch(0.2 0.005 285);
--secondary-foreground: oklch(0.93 0 0);
/* Muted */
--muted: oklch(0.2 0.005 285);
--muted-foreground: oklch(0.6 0 0);
/* Accent: same as primary (orange) */
--accent: oklch(0.702 0.183 54.13);
--accent-foreground: oklch(1 0 0);
/* Destructive */
--destructive: oklch(0.577 0.245 27.325);
/* Border: #26262c */
--border: oklch(0.23 0.005 285);
--input: oklch(0.23 0.005 285);
--ring: oklch(0.702 0.183 54.13);
/* Charts */
--chart-1: oklch(0.702 0.183 54.13);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
/* Sidebar */
--sidebar: oklch(0.13 0.005 285);
--sidebar-foreground: oklch(0.93 0 0);
--sidebar-primary: oklch(0.702 0.183 54.13);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2 0.005 285);
--sidebar-accent-foreground: oklch(0.93 0 0);
--sidebar-border: oklch(0.23 0.005 285);
--sidebar-ring: oklch(0.702 0.183 54.13);
}
/* Light theme override */
.light {
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.602 0.183 54.13);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.602 0.183 54.13);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.602 0.183 54.13);
--chart-1: oklch(0.602 0.183 54.13);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.602 0.183 54.13);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.602 0.183 54.13);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

44
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "@/components/providers/session-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { APP_NAME } from "@/lib/constants";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: APP_NAME,
template: `%s | ${APP_NAME}`,
},
description:
"Self-hosted inventory management for 3D printing filament, resin, and miniature paints",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}>
<SessionProvider>
<ThemeProvider>
<TooltipProvider delayDuration={0}>
{children}
<Toaster richColors position="bottom-right" />
</TooltipProvider>
</ThemeProvider>
</SessionProvider>
</body>
</html>
);
}

9
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { Flame } from "lucide-react";
export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center">
<Flame className="h-8 w-8 animate-pulse text-primary" />
</div>
);
}

14
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<h1 className="text-4xl font-bold">404</h1>
<p className="text-muted-foreground">Page not found</p>
<Button asChild>
<Link href="/dashboard">Back to Dashboard</Link>
</Button>
</div>
);
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,46 @@
"use client";
import { usePathname } from "next/navigation";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { UserMenu } from "./user-menu";
import { MobileSidebar } from "./mobile-sidebar";
const routeTitles: Record<string, string> = {
"/dashboard": "Dashboard",
"/filaments": "Filaments",
"/resins": "Resins",
"/paints": "Paints",
"/vendors": "Vendors",
"/locations": "Locations",
"/settings": "Settings",
};
export function Header() {
const pathname = usePathname();
const title = routeTitles[pathname] || "Dragon's Stash";
return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b border-border bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60 lg:px-6">
{/* Mobile menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-60 p-0">
<MobileSidebar />
</SheetContent>
</Sheet>
<h1 className="text-lg font-semibold">{title}</h1>
<div className="ml-auto">
<UserMenu />
</div>
</header>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Cylinder,
Droplets,
Paintbrush,
Building2,
MapPin,
Settings,
Flame,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { APP_NAME } from "@/lib/constants";
import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Building2, MapPin, Settings };
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard" as const },
{ label: "Filaments", href: "/filaments", icon: "Cylinder" as const },
{ label: "Resins", href: "/resins", icon: "Droplets" as const },
{ label: "Paints", href: "/paints", icon: "Paintbrush" as const },
{ label: "Vendors", href: "/vendors", icon: "Building2" as const },
{ label: "Locations", href: "/locations", icon: "MapPin" as const },
{ label: "Settings", href: "/settings", icon: "Settings" as const },
];
export function MobileSidebar() {
const pathname = usePathname();
return (
<div className="flex h-full flex-col">
<SheetHeader className="border-b border-border p-4">
<SheetTitle className="flex items-center gap-2">
<Flame className="h-5 w-5 text-primary" />
{APP_NAME}
</SheetTitle>
</SheetHeader>
<nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => {
const Icon = icons[item.icon];
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "border-l-2 border-primary bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Cylinder,
Droplets,
Paintbrush,
Building2,
MapPin,
Settings,
Flame,
PanelLeftClose,
PanelLeft,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { APP_NAME } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const icons = {
LayoutDashboard,
Cylinder,
Droplets,
Paintbrush,
Building2,
MapPin,
Settings,
} as const;
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard" as const },
{ label: "Filaments", href: "/filaments", icon: "Cylinder" as const },
{ label: "Resins", href: "/resins", icon: "Droplets" as const },
{ label: "Paints", href: "/paints", icon: "Paintbrush" as const },
{ label: "Vendors", href: "/vendors", icon: "Building2" as const },
{ label: "Locations", href: "/locations", icon: "MapPin" as const },
{ label: "Settings", href: "/settings", icon: "Settings" as const },
];
export function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
"flex h-screen flex-col border-r border-border bg-card transition-all duration-200",
collapsed ? "w-16" : "w-60"
)}
>
{/* Logo */}
<div className="flex h-14 items-center gap-2 border-b border-border px-4">
<Flame className="h-6 w-6 shrink-0 text-primary" />
{!collapsed && (
<span className="text-sm font-bold tracking-tight">{APP_NAME}</span>
)}
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => {
const Icon = icons[item.icon];
const isActive = pathname.startsWith(item.href);
const link = (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "border-l-2 border-primary bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Link>
);
if (collapsed) {
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
return link;
})}
</nav>
{/* Collapse toggle */}
<div className="border-t border-border p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-center"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? (
<PanelLeft className="h-4 w-4" />
) : (
<>
<PanelLeftClose className="h-4 w-4 mr-2" />
<span className="text-xs">Collapse</span>
</>
)}
</Button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { LogOut, Settings, User } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function UserMenu() {
const { data: session } = useSession();
if (!session?.user) return null;
const initials = session.user.name
? session.user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
: "U";
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted">
<Avatar className="h-7 w-7">
<AvatarImage src={session.user.image || undefined} />
<AvatarFallback className="bg-primary/20 text-xs text-primary">{initials}</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium md:inline-block">{session.user.name}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col">
<span className="text-sm">{session.user.name}</span>
<span className="text-xs text-muted-foreground">{session.user.email}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings">
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
export function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}

View File

@@ -0,0 +1,11 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { CatalogItem, CatalogItemType } from "@/types/catalog.types";
const API_PATHS: Record<CatalogItemType, string> = {
filament: "/api/catalog/filaments",
resin: "/api/catalog/resins",
paint: "/api/catalog/paints",
};
interface AutocompleteInputProps {
/** Which catalog to search */
type: CatalogItemType;
/** Called when a suggestion is selected — use this to auto-fill the form */
onSelectItem: (item: CatalogItem) => void;
/** The current text value of the input */
value: string;
/** Standard onChange for the text input */
onChange: (value: string) => void;
/** Input placeholder */
placeholder?: string;
/** Additional className for the input */
className?: string;
}
/**
* A text input with catalog autocomplete suggestions.
* When the user types ≥ 2 chars, it searches the catalog API and shows
* a dropdown of matching products. Selecting one calls onSelectItem
* to auto-fill the entire form.
*/
export function AutocompleteInput({
type,
onSelectItem,
value,
onChange,
placeholder,
className,
}: AutocompleteInputProps) {
const [suggestions, setSuggestions] = useState<CatalogItem[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const suppressRef = useRef(false);
// Fetch suggestions when value changes (debounced)
const fetchSuggestions = useCallback(
async (query: string) => {
if (query.length < 2) {
setSuggestions([]);
setIsOpen(false);
return;
}
try {
const params = new URLSearchParams({ search: query });
const resp = await fetch(`${API_PATHS[type]}?${params.toString()}`);
if (!resp.ok) return;
const data = await resp.json();
const items: CatalogItem[] = (data.items ?? []).slice(0, 8);
setSuggestions(items);
setIsOpen(items.length > 0);
setActiveIndex(-1);
} catch {
setSuggestions([]);
setIsOpen(false);
}
},
[type],
);
useEffect(() => {
// Don't fetch right after selecting an item
if (suppressRef.current) {
suppressRef.current = false;
return;
}
const timeout = setTimeout(() => {
fetchSuggestions(value);
}, 300);
return () => clearTimeout(timeout);
}, [value, fetchSuggestions]);
// Close on click outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function handleSelect(item: CatalogItem) {
suppressRef.current = true;
onSelectItem(item);
setIsOpen(false);
setSuggestions([]);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!isOpen || suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % suggestions.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
} else if (e.key === "Enter" && activeIndex >= 0) {
e.preventDefault();
handleSelect(suggestions[activeIndex]);
} else if (e.key === "Escape") {
setIsOpen(false);
}
}
function getSubLabel(item: CatalogItem): string | null {
if (item.type === "filament" && item.material) return item.material;
if (item.type === "resin" && item.resinType) return item.resinType;
if (item.type === "paint" && item.line) return item.line;
return null;
}
return (
<div ref={containerRef} className="relative">
<Input
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (suggestions.length > 0) setIsOpen(true);
}}
placeholder={placeholder}
className={className}
autoComplete="off"
/>
{isOpen && suggestions.length > 0 && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-md">
<div className="max-h-[240px] overflow-y-auto py-1">
{suggestions.map((item, idx) => {
const sub = getSubLabel(item);
return (
<button
key={item.id}
type="button"
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent",
idx === activeIndex && "bg-accent",
)}
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur before click
handleSelect(item);
}}
onMouseEnter={() => setActiveIndex(idx)}
>
{item.colorHex ? (
<ColorSwatch hex={item.colorHex} size="sm" className="shrink-0" />
) : (
<div className="h-4 w-4 shrink-0 rounded-sm border border-dashed border-border" />
)}
<span className="min-w-0 flex-1 truncate">{item.name}</span>
{sub && (
<Badge variant="secondary" className="shrink-0 text-[10px] px-1 py-0">
{sub}
</Badge>
)}
{item.price != null && (
<span className="shrink-0 text-xs text-muted-foreground">
${item.price.toFixed(2)}
</span>
)}
</button>
);
})}
</div>
<div className="border-t px-3 py-1 text-[10px] text-muted-foreground">
Select to auto-fill form
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CatalogBrowser } from "@/components/shared/catalog-browser";
import type { CatalogItem, CatalogItemType } from "@/types/catalog.types";
interface CatalogBrowserButtonProps {
type: CatalogItemType;
onSelect: (item: CatalogItem) => void;
}
export function CatalogBrowserButton({ type, onSelect }: CatalogBrowserButtonProps) {
const [open, setOpen] = useState(false);
return (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setOpen(true)}
>
<Search className="mr-2 h-4 w-4" />
Browse Catalog
</Button>
<CatalogBrowser
type={type}
open={open}
onOpenChange={setOpen}
onSelect={onSelect}
/>
</>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { CatalogItem, CatalogItemType, CatalogBrand } from "@/types/catalog.types";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import { ColorSwatch } from "@/components/shared/color-swatch";
import { Loader2 } from "lucide-react";
interface CatalogBrowserProps {
type: CatalogItemType;
onSelect: (item: CatalogItem) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const TYPE_LABELS: Record<CatalogItemType, string> = {
filament: "Filaments",
resin: "Resins",
paint: "Paints",
};
const API_PATHS: Record<CatalogItemType, string> = {
filament: "/api/catalog/filaments",
resin: "/api/catalog/resins",
paint: "/api/catalog/paints",
};
export function CatalogBrowser({
type,
onSelect,
open,
onOpenChange,
}: CatalogBrowserProps) {
const [items, setItems] = useState<CatalogItem[]>([]);
const [brands, setBrands] = useState<CatalogBrand[]>([]);
const [activeBrand, setActiveBrand] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [hasFetched, setHasFetched] = useState(false);
// Fetch catalog data when dialog opens
const fetchData = useCallback(
async (brandFilter?: string, searchFilter?: string) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (brandFilter) params.set("brand", brandFilter);
if (searchFilter && searchFilter.length >= 2) params.set("search", searchFilter);
const resp = await fetch(`${API_PATHS[type]}?${params.toString()}`);
if (!resp.ok) throw new Error("Failed to fetch catalog");
const data = await resp.json();
setItems(data.items ?? []);
if (data.brands) setBrands(data.brands);
} catch (err) {
console.error("Catalog fetch error:", err);
setItems([]);
} finally {
setLoading(false);
}
},
[type],
);
// Initial fetch when opening
useEffect(() => {
if (open && !hasFetched) {
setHasFetched(true);
fetchData();
}
if (!open) {
setHasFetched(false);
setActiveBrand(null);
setSearch("");
}
}, [open, hasFetched, fetchData]);
// Refetch when brand changes
useEffect(() => {
if (!open || !hasFetched) return;
fetchData(activeBrand ?? undefined);
}, [activeBrand, open, hasFetched, fetchData]);
function handleSelect(item: CatalogItem) {
onSelect(item);
onOpenChange(false);
}
// Debounced search
useEffect(() => {
if (!open || !hasFetched) return;
const timeout = setTimeout(() => {
fetchData(activeBrand ?? undefined, search || undefined);
}, 300);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
/** Secondary badge text based on item type */
function getSubLabel(item: CatalogItem): string | null {
if (item.type === "filament" && item.material) return item.material;
if (item.type === "resin" && item.resinType) return item.resinType;
if (item.type === "paint" && item.line) return item.line;
return null;
}
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title={`Browse ${TYPE_LABELS[type]} Catalog`}
description={`Search and select a product to auto-fill the form`}
>
<CommandInput
placeholder={`Search ${TYPE_LABELS[type].toLowerCase()}...`}
value={search}
onValueChange={setSearch}
/>
{/* Brand filter chips */}
{brands.length > 1 && (
<div className="flex flex-wrap gap-1.5 border-b px-3 py-2">
<Badge
variant={activeBrand === null ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() => setActiveBrand(null)}
>
All
</Badge>
{brands.map((b) => (
<Badge
key={b.id}
variant={activeBrand === b.name ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() =>
setActiveBrand(activeBrand === b.name ? null : b.name)
}
>
{b.name}
<span className="ml-1 opacity-60">{b.itemCount}</span>
</Badge>
))}
</div>
)}
<CommandList className="max-h-[400px]">
{loading && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading catalog...
</div>
)}
{!loading && items.length === 0 && (
<CommandEmpty>No products found.</CommandEmpty>
)}
{!loading && items.length > 0 && (
<CommandGroup heading={`${items.length} product${items.length !== 1 ? "s" : ""}`}>
{items.slice(0, 200).map((item) => {
const sub = getSubLabel(item);
return (
<CommandItem
key={item.id}
value={`${item.name} ${item.brand} ${item.color ?? ""} ${sub ?? ""}`}
onSelect={() => handleSelect(item)}
className="flex items-center gap-3 py-2"
>
{/* Colour swatch */}
{item.colorHex ? (
<ColorSwatch hex={item.colorHex} size="md" className="shrink-0" />
) : (
<div className="h-6 w-6 shrink-0 rounded-sm border border-dashed border-border" />
)}
{/* Name + brand */}
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-sm">
{item.name}
</span>
<span className="truncate text-xs text-muted-foreground">
{item.brand}
{item.color && item.color !== item.name
? `${item.color}`
: ""}
</span>
</div>
{/* Type / material badge */}
{sub && (
<Badge variant="secondary" className="shrink-0 text-xs">
{sub}
</Badge>
)}
{/* Finish badge for paints */}
{item.finish && item.finish !== "Matte" && (
<Badge variant="outline" className="shrink-0 text-xs">
{item.finish}
</Badge>
)}
{/* Price */}
{item.price != null && (
<span className="shrink-0 text-xs text-muted-foreground">
${item.price.toFixed(2)}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
);
}

View File

@@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
interface ColorSwatchProps {
hex: string;
size?: "sm" | "md" | "lg";
className?: string;
}
const sizeClasses = {
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
};
export function ColorSwatch({ hex, size = "sm", className }: ColorSwatchProps) {
return (
<div
className={cn(
"rounded-sm border border-border",
sizeClasses[size],
className
)}
style={{ backgroundColor: hex }}
title={hex}
/>
);
}
export function ColorPreviewStrip({ hex, className }: { hex: string; className?: string }) {
return (
<div
className={cn("h-full w-1 rounded-full", className)}
style={{ backgroundColor: hex }}
title={hex}
/>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { type Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface DataTableColumnHeaderProps<TData, TValue> {
column: Column<TData, TValue>;
title: string;
className?: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<Button
variant="ghost"
size="sm"
className={cn("-ml-3 h-8 text-xs", className)}
onClick={() => {
const currentSort = column.getIsSorted();
if (currentSort === false) {
column.toggleSorting(false);
} else if (currentSort === "asc") {
column.toggleSorting(true);
} else {
column.clearSorting();
}
}}
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-1 h-3 w-3" />
) : column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-1 h-3 w-3" />
) : (
<ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
)}
</Button>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { CheckIcon, PlusCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
interface FacetedFilterOption {
label: string;
value: string;
}
interface DataTableFacetedFilterProps {
title: string;
options: FacetedFilterOption[];
selectedValues: Set<string>;
onSelectionChange: (values: Set<string>) => void;
}
export function DataTableFacetedFilter({
title,
options,
selectedValues,
onSelectionChange,
}: DataTableFacetedFilterProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircle className="mr-2 h-3.5 w-3.5" />
{title}
{selectedValues.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
key={option.value}
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} className="h-9" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
const newValues = new Set(selectedValues);
if (isSelected) {
newValues.delete(option.value);
} else {
newValues.add(option.value);
}
onSelectionChange(newValues);
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-3.5 w-3.5" />
</div>
<span className="text-sm">{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => onSelectionChange(new Set())}
className="justify-center text-center text-xs"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

Some files were not shown because too many files have changed in this diff Show More