Compare commits

...
Sign in to create a new pull request.

53 commits

Author SHA1 Message Date
fullsizemalt
5fe22f03fb feat: Add demo Room records for Dashboard display
Adds 6 cultivation zones to seed.ts:
- Flower Room A/B (78°F, 55% RH)
- Veg Room 1/2 (76°F, 65% RH)
- Drying Room (62°F, 60% RH)
- Cure Vault (65°F, 58% RH)

These rooms are now returned by /api/rooms for Dashboard display.
2026-01-13 00:05:06 -08:00
fullsizemalt
eca58ecbc2 feat: Wire action buttons and add CreateBatchModal
- Wire 'Add Zone' button on RoomsPage to open wizard
- Create CreateBatchModal for batch creation form
- Wire 'New Batch' button on BatchesPage to open modal
- Modal includes strain, plant count, source, stage, room, METRC tags
2026-01-12 23:54:32 -08:00
fullsizemalt
d2abe033f2 feat: Wire Dashboard to real API data
BREAKING: Removed all mock data from DashboardPage

Changes:
- Fetch rooms from /api/rooms instead of MOCK_ROOMS
- Wire batch count from /api/batches
- Wire tasks due today from /api/tasks
- Wire avg temp/humidity from /api/pulse/readings
- Add loading skeleton state
- Add empty state with 'Create Zone' CTA when no rooms
- Wire 'View Room Details' and 'Resolve' buttons to navigation
2026-01-12 23:48:42 -08:00
fullsizemalt
58d38aef8a chore: Reduce WebSocket logging from info to debug
Connect/disconnect messages were flooding production logs.
Changed to log.debug level for cleaner output.
2026-01-12 22:57:19 -08:00
fullsizemalt
e7d23f4c7f fix: Remove floating 0 on Environment page
React renders 0 when using {0 && ...} pattern. Changed to proper
boolean comparison: {value !== undefined && value > 0 && (...)}
2026-01-12 16:18:06 -08:00
fullsizemalt
f2dc7526e7 fix: Use valid RoleEnum value STAFF (not WORKER) 2026-01-12 15:40:01 -08:00
fullsizemalt
555a22b846 fix: Use hashPassword utility instead of dynamic bcrypt import 2026-01-12 15:35:13 -08:00
fullsizemalt
458913bdf8 fix: Use passwordHash field name (matches Prisma schema) 2026-01-12 15:25:44 -08:00
fullsizemalt
7607dff622 feat: Photo persistence and user creation
- Change STORAGE_PATH from /tmp to /app/photos
- Add photos_data volume to docker-compose.yml for persistence
- Add createUser controller with bcrypt password hashing
- Add POST /users route for employee creation
2026-01-12 15:21:37 -08:00
fullsizemalt
3a62e94ad8 fix: Replace hardcoded task counts with real calculations
- Overdue: tasks with dueDate in the past
- Due Today: tasks due today
- Completed 24h: tasks completed in last 24 hours
2026-01-12 14:58:57 -08:00
fullsizemalt
e8babfc2eb fix: Replace blue slate skeleton colors with neutral zinc
- Change skeleton bg from slate to zinc for neutral appearance
- Update '777 Wolfpack' branding comment to 'Veridian'
2026-01-12 13:32:29 -08:00
fullsizemalt
875ae344f8 fix: Remove Pulse sensor section - references undefined flowerRoom 2026-01-12 13:11:33 -08:00
fullsizemalt
08992257bf fix: Remove roomId from demo batches - foreign key references wrong table 2026-01-12 11:47:31 -08:00
fullsizemalt
3b9da95fd9 fix: Simple timeless icon, favicon, correct BatchStage enums
- Replace tech circuit icon with simple clean leaf silhouette
- Add favicon.png and link in index.html
- Install icon to all mipmap directories including ic_launcher_foreground
- Fix BatchStage enums: FLOWERING, VEGETATIVE, DRYING (not FLOWER/VEG/DRY)
2026-01-12 11:04:33 -08:00
fullsizemalt
3fc1f6cc4e fix: Use correct Sensor field names in seed.ts (deviceId, isActive) 2026-01-12 10:38:39 -08:00
fullsizemalt
210cd16bbe feat: Demo polish - custom app icon, enhanced seed data
- Add custom Veridian app icon (olive green leaf + circuit design)
- Add 3 demo batches: GG4 (Flower), Kush Mints (Harvested), GDP (Veg)
- Add 4 demo tasks with 2 overdue for realistic demo
- Add Pulse sensor mapping for device 11666 to Flower Room
- Copy icon to all Android mipmap directories
2026-01-12 10:20:02 -08:00
fullsizemalt
95250185d0 fix: Critical UI polish - login redirect, remove DevTools, update branding
- Fix login redirect from /dashboard to / (correct index route)
- Remove DevTools component from Layout.tsx and LoginPage.tsx
- Update HomePage branding from 'CA Grow Ops Manager' to 'Veridian'
2026-01-11 23:02:20 -08:00
fullsizemalt
6c91d4cd42 refactor: Make Aspirant the official theme - remove toggle, lock to aspirant 2026-01-11 22:23:31 -08:00
fullsizemalt
f0db9d5e5f fix: APK tap-to-sign-in - reduce to 5 taps, use correct demo credentials 2026-01-11 17:14:59 -08:00
fullsizemalt
bf2fbe9b19 fix: Replace contaminated wolfpack-kiosk service worker with Veridian sw 2026-01-10 02:25:47 -08:00
fullsizemalt
1f7f722238 Fix nginx DNS resolver for Docker upstream
Use Docker internal DNS resolver and variable for backend upstream
to prevent nginx from failing to resolve hostname during config reload.
2026-01-09 01:44:39 -08:00
fullsizemalt
38ddfb00e3 Add no-cache headers for HTML files
Prevents browser caching of index.html to ensure fresh asset references
after deployments. Static assets remain cached with immutable flag.
2026-01-09 01:30:39 -08:00
fullsizemalt
6bdc23b9d3 Fix nginx config to serve static assets correctly
Add /assets/ location block to serve JS/CSS with correct MIME types
instead of falling back to index.html.
2026-01-09 01:24:41 -08:00
fullsizemalt
f6b9299d00 Add data-theme attribute to HTML for initial styling 2026-01-08 21:02:39 -08:00
fullsizemalt
7a19798c48 Fix DNS conflict by using unique container names
- Add container_name for backend and frontend services
- Update nginx proxy to use veridian-preview-backend hostname
- Fixes issue where DNS was resolving to wrong backend container

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 20:37:54 -08:00
fullsizemalt
4894679357 Fix PDF and branding routes to use /api prefix
- Add /api prefix to PDF and branding route registration in server.ts
- Remove /api prefix from individual route definitions
- Fixes routing issue where PDF endpoints were not accessible through nginx

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 19:13:35 -08:00
fullsizemalt
978286606d Bust TypeScript build cache 2026-01-08 16:08:41 -08:00
fullsizemalt
7c8964180c Force Docker rebuild to recompile TypeScript with PDF routes 2026-01-08 16:07:14 -08:00
fullsizemalt
9fe6823508 Fix branding service to use in-memory storage
Remove file system operations that were causing permission errors
in the Docker container. Branding configs now stored in memory only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 15:46:08 -08:00
fullsizemalt
d8f384d44a Bust Docker cache to fetch updated tinypdf-plus with dist 2026-01-08 15:41:53 -08:00
fullsizemalt
5aed125a60 Simplify Dockerfile by using pre-built tinypdf-plus
The tinypdf-plus package now includes pre-built dist files.
Removed bun installation and build step from Dockerfile.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 15:39:06 -08:00
fullsizemalt
80a1d87cac Add bun to PATH for tinypdf-plus build
The tinypdf-plus build script uses 'bun' directly without a full
path. Adding /root/.bun/bin to PATH so the build script can find
the bun binary.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 12:28:17 -08:00
fullsizemalt
d2151c8ee1 Fix bun installation in Dockerfile
The bun.sh install script requires bash. Install bash explicitly
and use it for the bun install script.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 12:25:17 -08:00
fullsizemalt
5063d95477 fix(mobile): Status bar safe area + Pulse API key + backup config 2026-01-08 12:24:17 -08:00
fullsizemalt
3673509a87 Install bun in Dockerfile for tinypdf-plus build
tinypdf-plus uses bun for its build process. Installing bun
in the builder container so the package can be built.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 12:24:02 -08:00
fullsizemalt
dcad331f48 Add explicit tinypdf-plus build step to Dockerfile
The Docker build cache was preventing postinstall from running.
Added explicit build step after npm install to ensure tinypdf-plus
dist folder is generated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 12:23:13 -08:00
fullsizemalt
4e8c9fd140 Add postinstall script to build tinypdf-plus
The tinypdf-plus package from GitHub needs to be built after
installation to generate the dist folder.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 12:19:43 -08:00
fullsizemalt
908c82916d Add git to Dockerfile for GitHub dependencies
The tinypdf-plus package is installed from GitHub and requires git
during npm install.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 12:15:54 -08:00
fullsizemalt
4bdbfc82ca Add tinypdf-plus integration with PDF generation and branding
Backend changes:
- Add tinypdf-plus dependency for TTF/OTF font support
- Create PDF service with text, certificate, and label generation
- Add PDF API endpoints (/api/pdf/*)
- Add branding service for custom fonts and styling
- Add branding API endpoints (/api/branding/*)
- Add font registration endpoint for custom fonts

Frontend changes:
- Add PDF library with download utilities
- Add PDFDownloadButton component for reusable PDF downloads
- Add branding API client for managing branding configs
- Update ReportsPage with PDF generation tab

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 12:07:35 -08:00
fullsizemalt
f4def70f24 fix(mobile): Add safe-area-inset-top padding to header for status bar 2026-01-08 11:48:19 -08:00
fullsizemalt
1837830a11 fix(backend): Fix dist path after rootDir removal 2026-01-08 11:07:06 -08:00
fullsizemalt
835c062c88 fix(backend): Compile seed.ts to JS for production use 2026-01-08 11:01:42 -08:00
fullsizemalt
3aa0277ab7 fix(backend): Run prisma db push and seed in Docker CMD 2026-01-08 10:54:57 -08:00
fullsizemalt
c4bfd6126d fix(deploy): Use hex encoding for passwords to avoid URL parsing errors 2026-01-08 02:23:33 -08:00
fullsizemalt
c7d1bfeb99 fix(auth): Always update password hash for existing users in seed 2026-01-08 02:07:49 -08:00
fullsizemalt
7386b5c6c5 fix(backend): Remove duplicate OPTIONS handler to resolve Startup Crash 2026-01-08 01:52:52 -08:00
fullsizemalt
8ed82cfab6 fix(backend): Downgrade cors and enable auto-seed 2026-01-08 01:43:49 -08:00
fullsizemalt
8b43744f4c fix(backend): Downgrade cors for fastify 4 compat and enable auto-seeding 2026-01-08 01:42:34 -08:00
fullsizemalt
fa9650d0b8 fix(deploy): Remove line numbers from env file generation 2026-01-08 01:26:11 -08:00
fullsizemalt
df208548f5 fix(deploy): Update script with corrected environment loading 2026-01-08 01:21:52 -08:00
fullsizemalt
b39cd90cf1 feat: Add Aspirant theme and preview deployment config 2026-01-08 01:16:41 -08:00
fullsizemalt
da32c67300 chore: Update deployment script for preview env 2026-01-08 01:11:19 -08:00
fullsizemalt
63d0e4ee2d feat(theme): Implement Aspirant theme switcher with DesignSwitch component 2026-01-08 00:46:48 -08:00
57 changed files with 3305 additions and 177 deletions

View file

@ -1,7 +1,7 @@
FROM node:20-alpine AS builder
# Install OpenSSL for Prisma
RUN apk add --no-cache openssl
# Install OpenSSL and Git for Prisma and GitHub dependencies
RUN apk add --no-cache openssl git
WORKDIR /app
@ -10,17 +10,17 @@ COPY package*.json ./
# Copy prisma directory if it exists, otherwise we'll handle it
COPY prisma ./prisma/
# Install dependencies
RUN npm install
# Install dependencies (CACHE BUST: 2025-01-08)
RUN npm install && echo "Cache bust: tinypdf-plus updated with dist"
# Copy source
# Copy source (CACHE BUST: 2025-01-08-2)
COPY . .
# Generate Prisma Client
RUN npx prisma generate
# Build TypeScript
RUN npm run build
# Build TypeScript (CACHE BUST: 2025-01-08-3)
RUN npm run build && echo "Build complete"
# Production image
FROM node:20-alpine
@ -41,4 +41,4 @@ USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
CMD ["sh", "-c", "npx prisma db push --skip-generate && npx prisma db seed && node dist/src/server.js"]

1056
backend/bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
{
"name": "ca-grow-ops-backend",
"version": "1.0.0",
"main": "dist/server.js",
"main": "dist/src/server.js",
"scripts": {
"build": "tsc",
"start": "npx prisma db push && node dist/server.js",
"start": "npx prisma db push && npx prisma db seed && node dist/src/server.js",
"dev": "ts-node-dev --transpile-only src/server.ts",
"test": "jest",
"lint": "eslint src/**/*.ts",
@ -13,9 +13,10 @@
"seed:all": "npx prisma db seed && node prisma/seed-demo.js"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
"seed": "node dist/prisma/seed.js"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^7.2.4",
"@fastify/multipart": "^8.0.0",
"@fastify/websocket": "^8.3.1",
@ -29,10 +30,10 @@
"fastify-plugin": "^4.5.0",
"jsonwebtoken": "^9.0.3",
"sharp": "^0.33.0",
"tinypdf-plus": "https://github.com/fullsizemalt/tinypdf-plus.git",
"zod": "^3.22.4"
},
"devDependencies": {
"@fastify/cors": "^11.2.0",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.0",
"eslint": "^8.56.0",

111
backend/prisma/map-pulse.ts Normal file
View file

@ -0,0 +1,111 @@
import { PrismaClient, SensorType } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Mapping Pulse sensor to Demo Room...');
// 1. Ensure Floor Exists
let floor = await prisma.facilityFloor.findFirst();
if (!floor) {
console.log('No floors found, creating default structure...');
let property = await prisma.facilityProperty.findFirst();
if (!property) {
property = await prisma.facilityProperty.create({
data: { name: 'Demo Facility' }
});
}
let building = await prisma.facilityBuilding.findFirst();
if (!building) {
building = await prisma.facilityBuilding.create({
data: {
propertyId: property.id,
name: 'Main Building',
code: 'MB',
type: 'CULTIVATION'
}
});
}
floor = await prisma.facilityFloor.create({
data: {
buildingId: building.id,
name: 'Ground Floor',
number: 1,
width: 100,
height: 100
}
});
}
// 2. Ensure Demo Room Exists
let room = await prisma.facilityRoom.findFirst({
where: { name: 'Demo Room' }
});
if (!room) {
console.log('Creating Demo Room...');
const defaultType = 'FLOWER'; // Assuming enum 'FLOWER' is valid for RoomType
// Note: RoomType is an enum in schema, need to match it.
// enum RoomType { VEG, FLOWER, DRY, CURE, MOTHER, CLONE, OTHER }
room = await prisma.facilityRoom.create({
data: {
floorId: floor.id,
name: 'Demo Room',
code: 'DEMO',
type: 'FLOWER',
posX: 0,
posY: 0,
width: 20,
height: 20
}
});
}
console.log(`Using Room: ${room.name} (${room.id})`);
// 3. Upsert Pulse Sensor
const sensorId = 'pulse-11666';
const existing = await prisma.sensor.findUnique({
where: { id: sensorId }
});
if (existing) {
console.log('Sensor already exists, updating connection...');
await prisma.sensor.update({
where: { id: sensorId },
data: {
roomId: room.id,
isActive: true,
type: 'VPD'
}
});
} else {
console.log('Creating new Pulse sensor...');
await prisma.sensor.create({
data: {
id: sensorId,
name: 'Veridian Demo Pulse',
type: 'VPD',
deviceId: '11666',
roomId: room.id,
isActive: true
}
});
}
console.log('✅ Pulse Sensor 11666 mapped to Demo Room');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -1,4 +1,5 @@
import { PrismaClient, RoomType, SectionType } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
@ -56,22 +57,42 @@ async function main() {
await prisma.user.create({
data: {
email: ownerEmail,
passwordHash: 'password123',
passwordHash: await bcrypt.hash('password123', 10),
name: 'Travis',
role: 'OWNER', // Enum fallback
roleId: ownerRole?.id,
rate: 100.00
}
});
console.log('Created Owner: Travis (admin@runfoo.com)');
console.log('Created Owner: Travis (admin@runfoo.run)');
} else {
// Update existing owner to have roleId if missing
if (!existingOwner.roleId && ownerRole) {
// Always ensure password is properly hashed and role is set
await prisma.user.update({
where: { email: ownerEmail },
data: { roleId: ownerRole.id, name: 'Travis' }
data: {
passwordHash: await bcrypt.hash('password123', 10),
roleId: ownerRole?.id || existingOwner.roleId,
name: 'Travis'
}
});
console.log('Updated Owner permissions');
console.log('Updated Owner password hash');
}
// Create Demo Rooms for Dashboard
const demoRooms = [
{ name: 'Flower Room A', type: RoomType.FLOWER, sqft: 500, targetTemp: 78, targetHumidity: 55, capacity: 100 },
{ name: 'Flower Room B', type: RoomType.FLOWER, sqft: 500, targetTemp: 78, targetHumidity: 55, capacity: 100 },
{ name: 'Veg Room 1', type: RoomType.VEG, sqft: 300, targetTemp: 76, targetHumidity: 65, capacity: 200 },
{ name: 'Veg Room 2', type: RoomType.VEG, sqft: 300, targetTemp: 76, targetHumidity: 65, capacity: 200 },
{ name: 'Drying Room', type: RoomType.DRY, sqft: 150, targetTemp: 62, targetHumidity: 60, capacity: 50 },
{ name: 'Cure Vault', type: RoomType.CURE, sqft: 200, targetTemp: 65, targetHumidity: 58, capacity: 100 }
];
for (const r of demoRooms) {
const existingRoom = await prisma.room.findFirst({ where: { name: r.name } });
if (!existingRoom) {
await prisma.room.create({ data: r });
console.log(`Created Room: ${r.name}`);
}
}
@ -281,6 +302,98 @@ async function main() {
}
}
// ============================================
// DEMO BATCHES
// ============================================
const owner = await prisma.user.findUnique({ where: { email: ownerEmail } });
const demoBatches = [
{
name: 'B-2026-01-GG4',
strain: 'Gorilla Glue #4',
startDate: new Date('2026-01-01'),
status: 'ACTIVE',
plantCount: 48,
source: 'CLONE',
stage: 'FLOWERING'
},
{
name: 'B-2025-12-KM',
strain: 'Kush Mints',
startDate: new Date('2025-12-15'),
harvestDate: new Date('2026-01-08'),
status: 'HARVESTED',
plantCount: 36,
source: 'CLONE',
stage: 'DRYING'
},
{
name: 'B-2026-01-GDP',
strain: 'Grand Daddy Purple',
startDate: new Date('2026-01-05'),
status: 'ACTIVE',
plantCount: 24,
source: 'SEED',
stage: 'VEGETATIVE'
}
];
for (const b of demoBatches) {
const existing = await prisma.batch.findFirst({ where: { name: b.name } });
if (!existing) {
await prisma.batch.create({ data: b as any });
console.log(`Created Batch: ${b.name} (${b.strain})`);
}
}
// ============================================
// DEMO TASKS
// ============================================
const demoTasks = [
{
title: 'IPM Treatment - Flower Room',
description: 'Apply weekly IPM spray to all flower room plants',
status: 'PENDING',
priority: 'HIGH',
dueDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days overdue
assignedToId: owner?.id
},
{
title: 'Nutrient Tank pH Calibration',
description: 'Calibrate pH meters and check EC levels in all nutrient tanks',
status: 'PENDING',
priority: 'MEDIUM',
dueDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // 1 day overdue
assignedToId: owner?.id
},
{
title: 'HVAC Filter Replacement',
description: 'Replace HEPA filters in all grow rooms',
status: 'PENDING',
priority: 'LOW',
dueDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // Due in 3 days
assignedToId: owner?.id
},
{
title: 'Weekly Crop Steering Review',
description: 'Review VPD charts and adjust environmental targets',
status: 'IN_PROGRESS',
priority: 'HIGH',
dueDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000),
assignedToId: owner?.id
}
];
for (const t of demoTasks) {
const existing = await prisma.task.findFirst({ where: { title: t.title } });
if (!existing) {
await prisma.task.create({ data: t as any });
console.log(`Created Task: ${t.title}`);
}
}
// Pulse sensor mapping skipped - requires proper Room entity setup
console.log('Seeding complete.');
}

View file

@ -1,4 +1,5 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { hashPassword } from '../utils/password';
export const getUsers = async (request: FastifyRequest, reply: FastifyReply) => {
// ... existing implementation ...
@ -57,3 +58,59 @@ export const updateUser = async (request: FastifyRequest, reply: FastifyReply) =
return reply.code(500).send({ message: 'Failed to update user' });
}
};
export const createUser = async (request: FastifyRequest, reply: FastifyReply) => {
const data = request.body as {
email: string;
password: string;
name?: string;
role?: string;
roleId?: string;
rate?: number;
};
// Validate required fields
if (!data.email || !data.password) {
return reply.code(400).send({ message: 'Email and password are required' });
}
try {
// Check if user already exists
const existing = await request.server.prisma.user.findUnique({
where: { email: data.email }
});
if (existing) {
return reply.code(409).send({ message: 'User with this email already exists' });
}
// Hash password
const hashedPassword = await hashPassword(data.password);
// Create user
const user = await request.server.prisma.user.create({
data: {
email: data.email,
passwordHash: hashedPassword,
name: data.name || data.email.split('@')[0],
role: (data.role as any) || 'STAFF',
...(data.roleId && { roleId: data.roleId }),
...(data.rate !== undefined && { rate: data.rate }),
},
select: {
id: true,
email: true,
name: true,
role: true,
userRole: true,
rate: true,
createdAt: true,
}
});
return reply.code(201).send(user);
} catch (error) {
request.log.error(error);
return reply.code(500).send({ message: 'Failed to create user' });
}
};

View file

@ -29,7 +29,7 @@ export async function websocketPlugin(fastify: FastifyInstance) {
const socket = connection.socket;
clients.set(clientId, socket);
fastify.log.info(`WebSocket client connected: ${clientId}`);
fastify.log.debug(`WebSocket client connected: ${clientId}`);
// Send welcome message
socket.send(JSON.stringify({
@ -53,7 +53,7 @@ export async function websocketPlugin(fastify: FastifyInstance) {
socket.on('close', () => {
clients.delete(clientId);
fastify.log.info(`WebSocket client disconnected: ${clientId}`);
fastify.log.debug(`WebSocket client disconnected: ${clientId}`);
});
socket.on('error', (error: Error) => {

View file

@ -0,0 +1,94 @@
/**
* Branding Routes
* API endpoints for managing PDF branding configurations
*/
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { brandingService } from '../services/branding.service';
const brandingSchema = z.object({
name: z.string().min(1).optional(),
titleFont: z.string().optional(),
bodyFont: z.string().optional(),
labelFont: z.string().optional(),
primaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
secondaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
accentColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
logoData: z.string().optional(),
});
export async function brandingRoutes(fastify: FastifyInstance) {
// Get all branding configurations
fastify.get('/', async (request, reply) => {
try {
const brandings = brandingService.listBrandings();
reply.send({ brandings });
} catch (error: any) {
reply.code(500).send({ error: 'Failed to list brandings', message: error?.message });
}
});
// Get a specific branding configuration
fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => {
try {
const { id } = request.params;
const branding = brandingService.getBranding(id);
if (!branding) {
return reply.code(404).send({ error: 'Branding not found' });
}
reply.send(branding);
} catch (error: any) {
reply.code(500).send({ error: 'Failed to get branding', message: error?.message });
}
});
// Create or update branding
fastify.post<{ Params: { id?: string } }>('/:id?', async (request, reply) => {
try {
const { id } = request.params;
const body = brandingSchema.parse(request.body);
const branding = await brandingService.saveBranding({
...body,
id,
});
reply.send(branding);
} catch (error: any) {
if (error.name === 'ZodError') {
return reply.code(400).send({ error: 'Invalid request', details: error.errors });
}
reply.code(500).send({ error: 'Failed to save branding', message: error?.message });
}
});
// Delete branding
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
try {
const { id } = request.params;
const deleted = await brandingService.deleteBranding(id);
if (!deleted) {
return reply.code(404).send({ error: 'Branding not found' });
}
reply.send({ success: true, message: 'Branding deleted' });
} catch (error: any) {
reply.code(500).send({ error: 'Failed to delete branding', message: error?.message });
}
});
// Get default branding
fastify.get('/default', async (request, reply) => {
try {
const branding = brandingService.getDefaultBranding();
reply.send(branding);
} catch (error: any) {
reply.code(500).send({ error: 'Failed to get default branding', message: error?.message });
}
});
}

View file

@ -0,0 +1,131 @@
/**
* PDF Generation Routes
* API endpoints for generating various types of PDFs
*/
import { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { pdfService } from '../services/pdf.service'
// Validation schemas
const textPDFSchema = z.object({
content: z.string().min(1),
options: z.object({
width: z.number().default(612),
height: z.number().default(792),
margin: z.number().default(72)
}).optional()
})
const certificateSchema = z.object({
title: z.string().min(1),
recipientName: z.string().min(1),
description: z.string().min(1),
date: z.string(),
certificateNumber: z.string().min(1),
authorizedBy: z.string().optional()
})
const labelSchema = z.object({
title: z.string().min(1),
subtitle: z.string().optional(),
qrCode: z.string().optional(),
details: z.array(z.object({
label: z.string(),
value: z.string()
})).optional().default([])
})
export async function pdfRoutes(fastify: FastifyInstance) {
// Register a custom font for PDF generation
fastify.post('/fonts/register', async (request, reply) => {
try {
const data = request.body as { name: string; fontData: string }; // base64 encoded font data
if (!data.name || !data.fontData) {
return reply.code(400).send({ error: 'Missing font name or data' });
}
// Convert base64 to Uint8Array
const binaryString = Buffer.from(data.fontData, 'base64').toString('binary');
const fontBytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
fontBytes[i] = binaryString.charCodeAt(i);
}
pdfService.registerFont(data.name, fontBytes);
reply.send({
success: true,
font: data.name,
message: `Font "${data.name}" registered successfully`
});
} catch (error: any) {
reply.code(500).send({ error: 'Font registration failed', message: error?.message || 'Unknown error' });
}
});
// List registered fonts
fastify.get('/fonts', async (request, reply) => {
try {
const fonts = pdfService.getRegisteredFonts();
reply.send({ fonts });
} catch (error: any) {
reply.code(500).send({ error: 'Failed to list fonts', message: error?.message || 'Unknown error' });
}
});
// Generate a simple text PDF
fastify.post('/text', async (request, reply) => {
try {
const body = textPDFSchema.parse(request.body)
const pdf = pdfService.generateTextPDF(body.content, body.options || { width: 612, height: 792, margin: 72 })
reply
.type('application/pdf')
.header('Content-Disposition', 'attachment; filename="document.pdf"')
.send(pdf)
} catch (error: any) {
reply.code(400).send({ error: 'Invalid request', message: error?.message || 'Unknown error' })
}
})
// Generate a certificate PDF
fastify.post('/certificate', async (request, reply) => {
try {
const body = certificateSchema.parse(request.body)
const pdf = pdfService.generateCertificate(body)
reply
.type('application/pdf')
.header('Content-Disposition', `attachment; filename="certificate-${body.certificateNumber}.pdf"`)
.send(pdf)
} catch (error: any) {
reply.code(400).send({ error: 'Invalid request', message: error?.message || 'Unknown error' })
}
})
// Generate a label PDF
fastify.post('/label', async (request, reply) => {
try {
const body = labelSchema.parse(request.body)
const pdf = pdfService.generateLabel(body)
reply
.type('application/pdf')
.header('Content-Disposition', 'attachment; filename="label.pdf"')
.send(pdf)
} catch (error: any) {
reply.code(400).send({ error: 'Invalid request', message: error?.message || 'Unknown error' })
}
})
// Health check for PDF service
fastify.get('/health', async (request, reply) => {
reply.send({
status: 'ok',
service: 'tinypdf-plus',
timestamp: new Date().toISOString()
})
})
}

View file

@ -4,8 +4,8 @@ import path from 'path';
import crypto from 'crypto';
import sharp from 'sharp';
// Storage base path - configurable via env
const STORAGE_PATH = process.env.STORAGE_PATH || '/tmp/ca-grow-ops-manager/photos';
// Storage base path - uses mounted volume for persistence
const STORAGE_PATH = process.env.STORAGE_PATH || '/app/photos';
// Image size configurations per spec
const IMAGE_SIZES = {

View file

@ -1,7 +1,8 @@
import { FastifyInstance } from 'fastify';
import { getUsers, updateUser } from '../controllers/users.controller';
import { getUsers, updateUser, createUser } from '../controllers/users.controller';
export async function userRoutes(server: FastifyInstance) {
server.get('/', getUsers);
server.post('/', createUser);
server.patch('/:id', updateUser);
}

View file

@ -41,13 +41,7 @@ server.register(cors, {
});
// Manual OPTIONS handler as fallback
server.options('/*', async (request, reply) => {
reply.header('Access-Control-Allow-Origin', request.headers.origin || '*');
reply.header('Access-Control-Allow-Credentials', 'true');
reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return reply.send();
});
// Manual OPTIONS handler removed as it conflicts with @fastify/cors plugin
server.register(prismaPlugin);
server.register(jwt, {
secret: process.env.JWT_SECRET || 'supersecret'
@ -78,6 +72,12 @@ server.register(messagingRoutes, { prefix: '/api/messaging' });
import { plantRoutes } from './routes/plants.routes';
server.register(plantRoutes, { prefix: '/api/plants' });
// PDF generation
import { pdfRoutes } from './routes/pdf.routes';
import { brandingRoutes } from './routes/branding.routes';
server.register(pdfRoutes, { prefix: '/api/pdf' });
server.register(brandingRoutes, { prefix: '/api/branding' });
// Phase 10: Compliance
import { auditRoutes } from './routes/audit.routes';
import { documentRoutes } from './routes/documents.routes';

View file

@ -0,0 +1,103 @@
/**
* Branding Service
* Manages custom branding settings for PDF generation
*/
import { randomUUID } from 'crypto';
export interface BrandingConfig {
id: string;
name: string;
// Font references (stored separately)
titleFont?: string;
bodyFont?: string;
labelFont?: string;
// Colors
primaryColor?: string;
secondaryColor?: string;
accentColor?: string;
// Logo
logoData?: string; // base64 encoded image
// Metadata
createdAt: Date;
updatedAt: Date;
}
class BrandingService {
private configs: Map<string, BrandingConfig> = new Map();
constructor() {
this.loadDefaultBranding();
}
private loadDefaultBranding() {
// Create a default branding configuration
const defaultBranding: BrandingConfig = {
id: 'default',
name: 'Default',
createdAt: new Date(),
updatedAt: new Date(),
};
this.configs.set('default', defaultBranding);
}
/**
* Get a branding configuration by ID
*/
getBranding(id: string): BrandingConfig | undefined {
return this.configs.get(id);
}
/**
* Get the default branding configuration
*/
getDefaultBranding(): BrandingConfig {
return this.configs.get('default')!;
}
/**
* Create or update a branding configuration
*/
async saveBranding(config: Partial<BrandingConfig>): Promise<BrandingConfig> {
const id = config.id || randomUUID();
const existing = this.configs.get(id);
const branding: BrandingConfig = {
id,
name: config.name || 'Custom Branding',
titleFont: config.titleFont,
bodyFont: config.bodyFont,
labelFont: config.labelFont,
primaryColor: config.primaryColor,
secondaryColor: config.secondaryColor,
accentColor: config.accentColor,
logoData: config.logoData,
createdAt: existing?.createdAt || new Date(),
updatedAt: new Date(),
};
this.configs.set(id, branding);
return branding;
}
/**
* List all branding configurations
*/
listBrandings(): BrandingConfig[] {
return Array.from(this.configs.values());
}
/**
* Delete a branding configuration
*/
async deleteBranding(id: string): Promise<boolean> {
if (id === 'default') {
throw new Error('Cannot delete default branding');
}
return this.configs.delete(id);
}
}
export const brandingService = new BrandingService();

View file

@ -0,0 +1,283 @@
/**
* PDF Generation Service
* Uses tinypdf-plus for server-side PDF generation with custom font support
*/
// @ts-ignore - tinypdf-plus doesn't have types yet
import { pdf, loadFont } from 'tinypdf-plus'
import { randomUUID } from 'crypto'
export interface PDFOptions {
title?: string
author?: string
subject?: string
keywords?: string
creator?: string
}
export interface TextOptions {
font?: string
color?: string
size?: number
align?: 'left' | 'center' | 'right'
width?: number
}
export interface PDFDocumentConfig {
width: number
height: number
margin: number
}
export interface FontConfig {
name: string
data: Uint8Array
}
class PDFService {
private registeredFonts = new Map<string, FontConfig>()
/**
* Register a custom font for PDF generation
*/
registerFont(name: string, fontData: Uint8Array): void {
try {
const font = loadFont(fontData, name)
this.registeredFonts.set(name, { name, data: fontData })
} catch (error) {
console.error(`Failed to register font ${name}:`, error)
throw new Error(`Font registration failed: ${error}`)
}
}
/**
* Get list of registered fonts
*/
getRegisteredFonts(): string[] {
return Array.from(this.registeredFonts.keys())
}
/**
* Check if a font is registered
*/
hasFont(name: string): boolean {
return this.registeredFonts.has(name)
}
/**
* Generate a simple text PDF
*/
generateTextPDF(
content: string,
options: PDFDocumentConfig = { width: 612, height: 792, margin: 72 }
): Uint8Array {
const doc = pdf()
// Register any custom fonts
for (const font of this.registeredFonts.values()) {
try {
const loadedFont = loadFont(font.data, font.name)
doc.registerFont(font.name, loadedFont)
} catch (error) {
console.warn(`Failed to register font ${font.name}:`, error)
}
}
doc.page((ctx: any) => {
const { width, height, margin } = options
const textWidth = width - margin * 2
let y = height - margin
const lineHeight = 14
// Simple text wrapping
const lines = this.wrapText(content, textWidth, 12)
for (const line of lines) {
ctx.text(line, margin, y, 12)
y -= lineHeight
if (y < margin) break
}
})
return doc.build()
}
/**
* Generate a certificate PDF
*/
generateCertificate(
data: {
title: string
recipientName: string
description: string
date: string
certificateNumber: string
authorizedBy?: string
},
options: PDFDocumentConfig = { width: 612, height: 792, margin: 72 }
): Uint8Array {
const doc = pdf()
// Register any custom fonts
for (const font of this.registeredFonts.values()) {
try {
const loadedFont = loadFont(font.data, font.name)
doc.registerFont(font.name, loadedFont)
} catch (error) {
console.warn(`Failed to register font ${font.name}:`, error)
}
}
doc.page((ctx: any) => {
const { width, height, margin } = options
const centerX = width / 2
let y = height - margin
// Title
ctx.text(data.title, centerX, y, 24, {
font: this.registeredFonts.has('Title') ? 'Title' : undefined,
align: 'center',
width: width - margin * 2
})
y -= 50
// Certificate number
ctx.text(`Certificate No: ${data.certificateNumber}`, centerX, y, 10, {
color: '#666',
align: 'center',
width: width - margin * 2
})
y -= 40
// Separator line
ctx.line(margin, y, width - margin, y, '#ccc', 1)
y -= 60
// Recipient name
ctx.text(data.recipientName, centerX, y, 18, {
font: this.registeredFonts.has('Body') ? 'Body' : undefined,
align: 'center',
width: width - margin * 2
})
y -= 30
// Description
const descriptionLines = this.wrapText(data.description, width - margin * 3, 12)
for (const line of descriptionLines) {
ctx.text(line, centerX, y, 12, {
align: 'center',
width: width - margin * 2
})
y -= 18
if (y < margin + 100) break
}
y -= 40
// Date
ctx.text(`Date: ${data.date}`, centerX, y, 12, {
color: '#666',
align: 'center',
width: width - margin * 2
})
// Authorized by (if provided)
if (data.authorizedBy) {
ctx.text(`Authorized by: ${data.authorizedBy}`, centerX, margin + 30, 10, {
color: '#666',
align: 'center',
width: width - margin * 2
})
}
})
return doc.build()
}
/**
* Generate a label PDF (for plants, batches, etc.)
*/
generateLabel(
data: {
title: string
subtitle?: string
qrCode?: string
details: { label: string; value: string }[]
},
options: PDFDocumentConfig = { width: 288, height: 144, margin: 18 }
): Uint8Array {
const doc = pdf()
// Register any custom fonts
for (const font of this.registeredFonts.values()) {
try {
const loadedFont = loadFont(font.data, font.name)
doc.registerFont(font.name, loadedFont)
} catch (error) {
console.warn(`Failed to register font ${font.name}:`, error)
}
}
doc.page((ctx: any) => {
const { width, height, margin } = options
let y = height - margin
// Title
ctx.text(data.title, margin, y, 14, {
font: this.registeredFonts.has('Label') ? 'Label' : undefined,
color: '#333'
})
y -= 20
// Subtitle (if provided)
if (data.subtitle) {
ctx.text(data.subtitle, margin, y, 10, { color: '#666' })
y -= 15
}
y -= 10
// Details
for (const detail of data.details) {
ctx.text(`${detail.label}: ${detail.value}`, margin, y, 9)
y -= 12
if (y < margin) break
}
// QR code placeholder (if provided)
if (data.qrCode) {
ctx.text(`QR: ${data.qrCode}`, margin, margin + 20, 8, { color: '#999' })
}
})
return doc.build()
}
/**
* Helper function to wrap text
*/
private wrapText(text: string, maxWidth: number, fontSize: number): string[] {
const lines: string[] = []
const words = text.split(' ')
let currentLine = ''
// Approximate character width (default Helvetica)
const avgCharWidth = fontSize * 0.5
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word
const testWidth = testLine.length * avgCharWidth
if (testWidth < maxWidth) {
currentLine = testLine
} else {
if (currentLine) lines.push(currentLine)
currentLine = word
}
}
if (currentLine) lines.push(currentLine)
return lines.length ? lines : ['']
}
}
export const pdfService = new PDFService()

View file

@ -6,14 +6,14 @@
"ES2020"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
"src/**/*",
"prisma/**/*"
],
"exclude": [
"node_modules",

View file

@ -1,7 +1,7 @@
#!/bin/bash
# CA Grow Ops Manager - Automated Deployment Script
# Usage: ./deploy.sh [env]
# Environments: test (default), prod
# Usage: ./deploy.sh [env] [branch]
# Environments: test (default), prod, preview
set -e # Exit on error
@ -12,6 +12,10 @@ ENV=${1:-test}
APP_NAME="ca-grow-ops-manager"
REPO_URL="https://git.runfoo.run/malty/ca-grow-ops-manager.git"
# Get current branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
BRANCH=${2:-$CURRENT_BRANCH} # Allow override or use current
# Define Environment Variables
case "$ENV" in
test)
@ -28,8 +32,15 @@ case "$ENV" in
PORT="8010"
ENV_DISPLAY="🔴 PROD (Tangible-Aacorn)"
;;
preview)
HOST="nexus-vector"
USER="admin"
DEPLOY_PATH="/srv/containers/veridian-preview"
PORT="8012"
ENV_DISPLAY="🟣 PREVIEW (Veridian-Preview on Nexus-Vector)"
;;
*)
echo "Error: Unknown environment '$ENV'. Use 'test' or 'prod'."
echo "Error: Unknown environment '$ENV'. Use 'test', 'prod', or 'preview'."
exit 1
;;
esac
@ -39,6 +50,7 @@ echo "=============================================="
echo "Target: $ENV_DISPLAY"
echo "Host: $USER@$HOST"
echo "Path: $DEPLOY_PATH"
echo "Branch: $BRANCH"
echo "=============================================="
echo ""
@ -70,7 +82,7 @@ echo ""
# Step 2: Push to Forgejo (Local)
echo -e "${BLUE}Step 2: Pushing code to Forgejo...${NC}"
git push origin main
git push origin $BRANCH
echo -e "${GREEN}✓ Code pushed${NC}"
echo ""
@ -85,10 +97,10 @@ echo -e "${BLUE}Step 4: Syncing repository on $HOST...${NC}"
ssh "$USER@$HOST" "
if [ ! -d $DEPLOY_PATH/.git ]; then
echo ' Cloning repository...'
git clone $REPO_URL $DEPLOY_PATH
git clone -b $BRANCH $REPO_URL $DEPLOY_PATH
else
echo ' Pulling latest changes...'
cd $DEPLOY_PATH && git pull origin main
cd $DEPLOY_PATH && git fetch origin && git checkout $BRANCH && git pull origin $BRANCH
fi
"
echo -e "${GREEN}✓ Code synced${NC}"
@ -101,8 +113,9 @@ HAS_ENV=$(ssh "$USER@$HOST" "[ -f $ENV_FILE ] && echo 'yes' || echo 'no'")
if [ "$HAS_ENV" = "no" ]; then
echo -e "${YELLOW}Creating new environment file on remote...${NC}"
DB_PASSWORD=$(openssl rand -base64 32)
JWT_SECRET=$(openssl rand -base64 64)
# Use hex encoding to avoid special characters that break database URLs
DB_PASSWORD=$(openssl rand -hex 24)
JWT_SECRET=$(openssl rand -hex 48)
ssh "$USER@$HOST" "cat > $ENV_FILE << EOF
# Database
@ -113,6 +126,7 @@ JWT_SECRET=${JWT_SECRET}
# Environment
NODE_ENV=production
PORT=${PORT}
EOF"
echo -e "${GREEN}✓ Environment file created${NC}"
echo -e "${YELLOW}IMPORTANT: New secrets generated on $HOST.${NC}"
@ -125,14 +139,17 @@ echo ""
echo -e "${BLUE}Step 6: Deploying services...${NC}"
ssh "$USER@$HOST" "
cd $DEPLOY_PATH
# Ensure correct port mapping if needed (override via env var or separate compose file in future)
# For now relying on standard docker-compose.yml
echo ' Building containers...'
docker compose build
# Use --env-file to ensure variables are loaded
docker compose --env-file docker-compose.env build
echo ' Starting services...'
docker compose up -d
if [ \"$ENV\" = \"preview\" ]; then
docker compose --env-file docker-compose.env -f docker-compose.yml -f docker-compose.preview.yml up -d --build
else
docker compose --env-file docker-compose.env up -d --build
fi
"
echo -e "${GREEN}✓ Services deployed${NC}"
echo ""

View file

@ -0,0 +1,10 @@
version: '3.8'
services:
frontend:
ports:
- "${PORT}:80"
labels:
- "traefik.enable=false"
go2rtc:
labels:
- "traefik.enable=false"

View file

@ -30,6 +30,7 @@ services:
retries: 5
backend:
container_name: veridian-preview-backend
build:
context: ./backend
dockerfile: Dockerfile
@ -56,6 +57,8 @@ services:
timeout: 10s
retries: 3
start_period: 40s
volumes:
- photos_data:/app/photos
go2rtc:
image: alexxit/go2rtc:latest
@ -93,6 +96,7 @@ services:
- go2rtc
frontend:
container_name: veridian-preview-frontend
build:
context: ./frontend
dockerfile: Dockerfile
@ -119,3 +123,4 @@ networks:
volumes:
db_data:
photos_data:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

After

Width:  |  Height:  |  Size: 307 KiB

View file

@ -1,16 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#10b981" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Veridian" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>Veridian - Cultivation Platform</title>
<html lang="en" data-theme="default">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Merriweather:wght@300;400;700;900&display=swap"
rel="stylesheet">
<meta charset="UTF-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#10b981" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Veridian" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>Veridian - Cultivation Platform</title>
</head>
<body>

View file

@ -4,9 +4,13 @@ server {
root /usr/share/nginx/html;
index index.html;
# DNS resolver for Docker
resolver 127.0.0.11 valid=30s;
# API proxy
location /api {
proxy_pass http://backend:3000;
set $backend_upstream "veridian-preview-backend:3000";
proxy_pass http://$backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@ -30,9 +34,22 @@ server {
add_header Content-Disposition "attachment; filename=visitorkiosk.apk";
}
# Static assets (JS, CSS, images, fonts)
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML files - no cache to ensure fresh asset references
location ~* \.(html)$ {
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Frontend SPA routing
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Security headers

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 307 KiB

View file

@ -1,55 +1,28 @@
const CACHE_NAME = 'wolfpack-kiosk-v1';
const CACHE_NAME = 'veridian-v2';
const STATIC_ASSETS = [
'/kiosk',
'/badges',
'/',
'/manifest.json'
];
// Install event - cache static assets
// Install event - skip waiting immediately to invalidate old cache
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// Activate event - clean up old caches
// Activate event - clean up ALL old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
cacheNames.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// Fetch event - network first, falling back to cache
// Fetch event - network only, no caching (to prevent stale assets)
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') return;
// Skip API requests (always fetch from network)
if (event.request.url.includes('/api/')) return;
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone and cache the response
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(event.request);
})
);
// Just pass through to network
return;
});

View file

@ -0,0 +1,208 @@
import { useState, useEffect } from 'react';
import { X, Loader2 } from 'lucide-react';
import { batchesApi } from '../lib/batchesApi';
import api from '../lib/api';
import { useToast } from '../context/ToastContext';
interface CreateBatchModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function CreateBatchModal({ isOpen, onClose, onSuccess }: CreateBatchModalProps) {
const { addToast } = useToast();
const [loading, setLoading] = useState(false);
const [rooms, setRooms] = useState<any[]>([]);
const [formData, setFormData] = useState({
name: '',
strain: '',
plantCount: 1,
source: 'CLONE' as 'CLONE' | 'SEED',
stage: 'CLONE_IN' as string,
roomId: '',
metrcTags: ''
});
useEffect(() => {
if (isOpen) {
loadRooms();
}
}, [isOpen]);
const loadRooms = async () => {
try {
const { data } = await api.get('/rooms');
setRooms(data);
} catch (err) {
console.error('Failed to load rooms:', err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const payload = {
...formData,
plantCount: parseInt(String(formData.plantCount)),
roomId: formData.roomId || undefined,
metrcTags: formData.metrcTags ? formData.metrcTags.split(',').map(t => t.trim()) : undefined
};
await batchesApi.create(payload);
addToast('Batch created successfully', 'success');
onSuccess();
onClose();
} catch (err: any) {
addToast(err.response?.data?.message || 'Failed to create batch', 'error');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-[var(--color-bg-elevated)] rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden border border-[var(--color-border-subtle)]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[var(--color-border-subtle)]">
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">Create New Batch</h2>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-[var(--color-bg-tertiary)] transition-colors"
>
<X size={20} className="text-[var(--color-text-tertiary)]" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
Batch Name *
</label>
<input
type="text"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., B001-OG Kush"
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
/>
</div>
<div>
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
Strain *
</label>
<input
type="text"
required
value={formData.strain}
onChange={e => setFormData({ ...formData, strain: e.target.value })}
placeholder="e.g., OG Kush"
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
Plant Count *
</label>
<input
type="number"
required
min={1}
value={formData.plantCount}
onChange={e => setFormData({ ...formData, plantCount: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
/>
</div>
<div>
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
Source *
</label>
<select
value={formData.source}
onChange={e => setFormData({ ...formData, source: e.target.value as 'CLONE' | 'SEED' })}
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
>
<option value="CLONE">Clone</option>
<option value="SEED">Seed</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
Starting Stage
</label>
<select
value={formData.stage}
onChange={e => setFormData({ ...formData, stage: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
>
<option value="CLONE_IN">Clone In</option>
<option value="VEGETATIVE">Vegetative</option>
<option value="FLOWERING">Flowering</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
Assign to Room
</label>
<select
value={formData.roomId}
onChange={e => setFormData({ ...formData, roomId: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
>
<option value="">Unassigned</option>
{rooms.map(room => (
<option key={room.id} value={room.id}>{room.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
METRC Tags (comma-separated)
</label>
<input
type="text"
value={formData.metrcTags}
onChange={e => setFormData({ ...formData, metrcTags: e.target.value })}
placeholder="e.g., 1A400100001234, 1A400100001235"
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-xl border border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] font-medium hover:bg-[var(--color-bg-tertiary)] transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2.5 rounded-xl bg-[var(--color-primary)] text-[var(--color-text-inverse)] font-bold hover:bg-[var(--color-primary-hover)] transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading && <Loader2 size={16} className="animate-spin" />}
Create Batch
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { useTheme } from '../context/ThemeContext';
import { Palette } from 'lucide-react';
export default function DesignSwitch() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'default' ? 'aspirant' : 'default')}
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors duration-fast text-sm font-medium
${theme === 'aspirant'
? 'text-[var(--color-primary)] bg-[var(--color-primary-soft)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)]'
}`}
title="Switch Design Theme"
>
<Palette size={16} />
<span className="hidden sm:inline text-xs">{theme === 'aspirant' ? 'Aspirant' : 'Classic'}</span>
</button>
);
}

View file

@ -9,11 +9,12 @@ import { CommandPalette } from './ui/CommandPalette';
import { SessionTimeoutWarning } from './ui/SessionTimeoutWarning';
import { PageTitleUpdater } from '../hooks/usePageTitle';
import AnnouncementBanner from './AnnouncementBanner';
import { DevTools } from './dev/DevTools';
import { Breadcrumbs } from './ui/Breadcrumbs';
import { pageVariants } from '../lib/animations';
import { Search, Bell, Settings, Filter, ChevronDown } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import { UserMenu } from './layout/UserMenu';
import { NotificationBell } from './notifications/NotificationBell';
@ -55,7 +56,10 @@ export default function Layout() {
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
{/* Topbar - Search, Global Filters, Vitals */}
<header className="h-16 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-secondary)]/80 backdrop-blur-xl z-20">
<header
className="h-16 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-secondary)]/80 backdrop-blur-xl z-20"
style={{ paddingTop: 'env(safe-area-inset-top)' }}
>
<div className="flex items-center gap-4 flex-1">
{/* Facility Switcher / Filter */}
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-tertiary)] border border-[var(--color-border-subtle)] cursor-pointer hover:border-[var(--color-border-default)] transition-all">
@ -137,7 +141,7 @@ export default function Layout() {
{/* System Utilities */}
<CommandPalette />
<SessionTimeoutWarning />
<DevTools />
</div>
);
}

View file

@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
import { cn } from '../../lib/utils';
/**
* 777 Wolfpack UI Primitives
* Veridian UI Primitives
* High-performance, high-density components for operational management.
*/
@ -230,7 +230,7 @@ export function StatusBadge({ status, label, className }: StatusBadgeProps) {
// Skeleton loader
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("animate-pulse bg-slate-100 dark:bg-slate-800 rounded", className)} />;
return <div className={cn("animate-pulse bg-zinc-200 dark:bg-zinc-800 rounded", className)} />;
}
// Card skeleton

View file

@ -0,0 +1,180 @@
/**
* PDF Download Button Component
* Reusable button for downloading PDFs
*/
import { Button } from './button';
import { Download, FileText, Award, Tag } from 'lucide-react';
import { useState } from 'react';
type PDFType = 'text' | 'certificate' | 'label';
interface PDFDownloadButtonProps {
type: PDFType;
data: unknown;
filename?: string;
children?: React.ReactNode;
variant?: 'default' | 'outline' | 'secondary' | 'ghost';
size?: 'default' | 'sm' | 'lg';
className?: string;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
const iconMap = {
text: FileText,
certificate: Award,
label: Tag,
};
export function PDFDownloadButton({
type,
data,
filename,
children,
variant = 'outline',
size = 'default',
className = '',
onSuccess,
onError,
}: PDFDownloadButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const Icon = iconMap[type];
const handleClick = async () => {
setIsLoading(true);
try {
const { generateTextPDF, generateCertificatePDF, generateLabelPDF } = await import('@/lib/pdf');
switch (type) {
case 'text':
await generateTextPDF(data as any, filename);
break;
case 'certificate':
await generateCertificatePDF(data as any, filename);
break;
case 'label':
await generateLabelPDF(data as any, filename);
break;
}
onSuccess?.();
} catch (error) {
console.error('PDF generation failed:', error);
onError?.(error as Error);
} finally {
setIsLoading(false);
}
};
return (
<Button
variant={variant}
size={size}
onClick={handleClick}
disabled={isLoading}
className={className}
>
{isLoading ? (
<>Generating...</>
) : (
<>
<Icon className="w-4 h-4" />
{children || <Download className="w-4 h-4" />}
</>
)}
</Button>
);
}
/**
* Certificate PDF Download Button
* Pre-configured for certificate generation
*/
interface CertificateDownloadButtonProps {
data: {
title: string;
recipientName: string;
description: string;
date: string;
certificateNumber: string;
authorizedBy?: string;
};
filename?: string;
variant?: 'default' | 'outline' | 'secondary' | 'ghost';
size?: 'default' | 'sm' | 'lg';
className?: string;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
export function CertificateDownloadButton({
data,
filename,
variant = 'default',
size = 'default',
className = '',
onSuccess,
onError,
}: CertificateDownloadButtonProps) {
return (
<PDFDownloadButton
type="certificate"
data={data}
filename={filename}
variant={variant}
size={size}
className={className}
onSuccess={onSuccess}
onError={onError}
>
<Award className="w-4 h-4" />
Download Certificate
</PDFDownloadButton>
);
}
/**
* Label PDF Download Button
* Pre-configured for label generation (plants, batches, etc.)
*/
interface LabelDownloadButtonProps {
data: {
title: string;
subtitle?: string;
qrCode?: string;
details: Array<{ label: string; value: string }>;
};
filename?: string;
variant?: 'default' | 'outline' | 'secondary' | 'ghost';
size?: 'default' | 'sm' | 'lg';
className?: string;
icon?: React.ReactNode;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
export function LabelDownloadButton({
data,
filename,
variant = 'outline',
size = 'sm',
className = '',
icon,
onSuccess,
onError,
}: LabelDownloadButtonProps) {
return (
<PDFDownloadButton
type="label"
data={data}
filename={filename}
variant={variant}
size={size}
className={className}
onSuccess={onSuccess}
onError={onError}
>
{icon || <Tag className="w-3 h-3" />}
</PDFDownloadButton>
);
}

View file

@ -0,0 +1,35 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'default' | 'aspirant';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Aspirant is now the official theme - no switching available
const [theme] = useState<Theme>('aspirant');
const setTheme = (_theme: Theme) => { }; // No-op, theme is locked
useEffect(() => {
localStorage.setItem('veridian-theme', theme);
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View file

@ -11,7 +11,8 @@
/* ============================================
CSS Custom Properties (Design Tokens)
============================================ */
:root {
:root,
[data-theme="default"] {
/* Font families */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-display: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@ -778,3 +779,105 @@
}
}
}
/* ===== THEME: Aspirant (Agave & Bone) ===== */
[data-theme="aspirant"] {
/* Backgrounds - Bone scale */
--color-bg-primary: #FDFCF8;
--color-bg-secondary: #F5F2EB; /* slightly darker stone */
--color-bg-tertiary: #E6E0D4;
--color-bg-elevated: #F5F2EB;
/* Text - Charcoal */
--color-text-primary: #2C2A26;
--color-text-secondary: #5C554B;
--color-text-tertiary: #847C6F;
--color-text-quaternary: #A8A195;
--color-text-inverse: #FDFCF8;
/* Borders */
--color-border-default: #E6E0D4;
--color-border-subtle: #F5F2EB;
--color-border-strong: #A8A195;
/* Primary (Agave) */
--color-primary: #4B7F79;
--color-primary-hover: #3D6A64;
--color-primary-soft: rgba(75, 127, 121, 0.15);
/* Accent (Gold) */
--color-accent: #E0A96D;
--color-accent-hover: #D49B5C;
--color-accent-soft: rgba(224, 169, 109, 0.15);
/* Status Colors (Earthy) */
--color-success: #6A994E;
--color-warning: #E9C46A;
--color-error: #C78D75;
--color-info: #4B7F79;
/* Chart colors */
--color-chart-green: #4B7F79;
--color-chart-orange: #E0A96D;
--color-chart-blue: #5F8D87;
--color-chart-purple: #8B735B;
--color-chart-gridline: #E6E0D4;
/* Layout override */
--card-radius: 12px;
/* Fonts */
--font-sans: 'Lato', sans-serif;
--font-display: 'Merriweather', serif;
/* shadcn override */
--background: 48 20% 98%;
--foreground: 33 8% 16%;
--card: 48 20% 98%;
--card-foreground: 33 8% 16%;
--primary: 172 26% 40%;
--primary-foreground: 48 20% 98%;
--secondary: 48 20% 94%;
--secondary-foreground: 33 8% 16%;
--muted: 48 20% 94%;
--muted-foreground: 33 8% 40%;
--accent: 33 65% 65%;
--accent-foreground: 48 20% 98%;
--destructive: 18 45% 62%;
--destructive-foreground: 48 20% 98%;
--border: 48 20% 90%;
--input: 48 20% 90%;
--ring: 172 26% 40%;
--radius: 0.75rem;
}
/* Aspirant Dark Mode Override */
[data-theme="aspirant"].dark,
[data-theme="aspirant"] .dark {
/* Backgrounds - Obsidian */
--color-bg-primary: #1A1916;
--color-bg-secondary: #2C2A26;
--color-bg-tertiary: #3D3A35;
--color-bg-elevated: #2C2A26;
/* Text - Bone */
--color-text-primary: #FDFCF8;
--color-text-secondary: #A8A195;
--color-text-tertiary: #847C6F;
--color-text-quaternary: #5C554B;
--color-text-inverse: #1A1916;
/* Borders */
--color-border-default: #3D3A35;
--color-border-subtle: #2C2A26;
--color-border-strong: #4B4842;
/* Primary (Agave preserved) */
--color-primary: #4B7F79;
--color-primary-hover: #5F8D87;
/* shadcn override */
--background: 33 8% 10%;
--foreground: 48 20% 98%;
--card: 33 8% 16%;
--card-foreground: 48 20% 98%;
}

View file

@ -0,0 +1,144 @@
/**
* Branding API
* Handles PDF branding configuration management
*/
import { Capacitor } from '@capacitor/core';
import api from './api';
const API_BASE_URL = Capacitor.isNativePlatform()
? 'https://veridian.runfoo.run'
: (import.meta.env.VITE_API_URL || '');
export interface BrandingConfig {
id: string;
name: string;
titleFont?: string;
bodyFont?: string;
labelFont?: string;
primaryColor?: string;
secondaryColor?: string;
accentColor?: string;
logoData?: string;
createdAt: string;
updatedAt: string;
}
/**
* Get all branding configurations
*/
export async function getAllBrandings(): Promise<BrandingConfig[]> {
const response = await api.get<{ brandings: BrandingConfig[] }>('/branding');
return response.data.brandings;
}
/**
* Get a specific branding configuration
*/
export async function getBranding(id: string): Promise<BrandingConfig> {
const response = await api.get<BrandingConfig>(`/branding/${id}`);
return response.data;
}
/**
* Get the default branding configuration
*/
export async function getDefaultBranding(): Promise<BrandingConfig> {
const response = await api.get<BrandingConfig>('/branding/default');
return response.data;
}
/**
* Create or update a branding configuration
*/
export async function saveBranding(
id: string | undefined,
data: Partial<BrandingConfig>
): Promise<BrandingConfig> {
const url = id ? `/branding/${id}` : '/branding';
const response = await api.post<BrandingConfig>(url, data);
return response.data;
}
/**
* Delete a branding configuration
*/
export async function deleteBranding(id: string): Promise<{ success: boolean; message: string }> {
const response = await api.delete<{ success: boolean; message: string }>(`/branding/${id}`);
return response.data;
}
/**
* Register a custom font for PDF generation
*/
export async function registerFont(name: string, fontData: string): Promise<{
success: boolean;
font: string;
message: string;
}> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/api/pdf/fonts/register`, {
method: 'POST',
headers,
body: JSON.stringify({ name, fontData }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to register font');
}
return response.json();
}
/**
* Get list of registered fonts
*/
export async function getRegisteredFonts(): Promise<string[]> {
const response = await fetch(`${API_BASE_URL}/api/pdf/fonts`);
if (!response.ok) {
throw new Error(`Failed to get fonts: ${response.statusText}`);
}
const data = await response.json();
return data.fonts || [];
}
/**
* Convert a file to base64
*/
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Remove data URL prefix (e.g., "data:font/ttf;base64,")
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* Convert an image file to base64
*/
export function imageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}

145
frontend/src/lib/pdf.ts Normal file
View file

@ -0,0 +1,145 @@
/**
* PDF Generation API
* Handles PDF generation using tinypdf-plus backend
*/
import { Capacitor } from '@capacitor/core';
const API_BASE_URL = Capacitor.isNativePlatform()
? 'https://veridian.runfoo.run'
: (import.meta.env.VITE_API_URL || '');
export interface TextPDFOptions {
content: string;
options?: {
width?: number;
height?: number;
margin?: number;
};
}
export interface CertificatePDFData {
title: string;
recipientName: string;
description: string;
date: string;
certificateNumber: string;
authorizedBy?: string;
}
export interface LabelPDFData {
title: string;
subtitle?: string;
qrCode?: string;
details: Array<{
label: string;
value: string;
}>;
}
/**
* Helper function to download PDF blob
*/
function downloadPDF(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
/**
* Generate a simple text PDF
*/
export async function generateTextPDF(data: TextPDFOptions, filename = 'document.pdf'): Promise<void> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/api/pdf/text`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to generate PDF: ${response.statusText}`);
}
const blob = await response.blob();
downloadPDF(blob, filename);
}
/**
* Generate a certificate PDF
*/
export async function generateCertificatePDF(
data: CertificatePDFData,
filename?: string
): Promise<void> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/api/pdf/certificate`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to generate certificate: ${response.statusText}`);
}
const blob = await response.blob();
const defaultFilename = `certificate-${data.certificateNumber}.pdf`;
downloadPDF(blob, filename || defaultFilename);
}
/**
* Generate a label PDF (for plants, batches, etc.)
*/
export async function generateLabelPDF(data: LabelPDFData, filename = 'label.pdf'): Promise<void> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/api/pdf/label`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to generate label: ${response.statusText}`);
}
const blob = await response.blob();
downloadPDF(blob, filename);
}
/**
* Check PDF service health
*/
export async function checkPDFHealth(): Promise<{ status: string; service: string; timestamp: string }> {
const response = await fetch(`${API_BASE_URL}/api/pdf/health`);
if (!response.ok) {
throw new Error(`PDF health check failed: ${response.statusText}`);
}
return response.json();
}

View file

@ -2,12 +2,15 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { ThemeProvider } from './context/ThemeContext'
// Initialize i18n
import './lib/i18n'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>,
)

View file

@ -6,6 +6,7 @@ import { useToast } from '../context/ToastContext';
import BatchTransitionModal from '../components/BatchTransitionModal';
import WeightLogModal from '../components/WeightLogModal';
import CreateTaskModal from '../components/tasks/CreateTaskModal';
import CreateBatchModal from '../components/CreateBatchModal';
import IPMScheduleModal from '../components/IPMScheduleModal';
import ScoutingModal from '../components/ipm/ScoutingModal';
import { DataTable, Column } from '../components/ui/DataTable';
@ -63,6 +64,7 @@ export default function BatchesPage() {
const [createTaskBatch, setCreateTaskBatch] = useState<Batch | null>(null);
const [ipmBatch, setIpmBatch] = useState<Batch | null>(null);
const [scoutingBatch, setScoutingBatch] = useState<Batch | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
useEffect(() => {
fetchBatches();
@ -205,7 +207,10 @@ export default function BatchesPage() {
</div>
</div>
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all">
<button
onClick={() => setIsCreateOpen(true)}
className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
>
<Plus size={18} />
New Batch
</button>
@ -304,6 +309,14 @@ export default function BatchesPage() {
}}
/>
)}
<CreateBatchModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onSuccess={() => {
fetchBatches();
setIsCreateOpen(false);
}}
/>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import {
AlertCircle,
@ -13,11 +14,13 @@ import {
Droplets,
Leaf,
Activity,
MoreHorizontal
Plus,
Wand2
} from 'lucide-react';
import { Card } from '../components/ui/card';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '../lib/utils';
import api from '../lib/api';
// Types
type RoomStatus = 'OK' | 'WARNING' | 'CRITICAL';
@ -27,38 +30,176 @@ type Trend = 'up' | 'down' | 'stable';
interface Room {
id: string;
name: string;
phase: RoomPhase;
type: string;
phase?: RoomPhase;
status: RoomStatus;
strains: string[];
metrics: { temp: number; humidity: number; vpd: number; co2: number };
trend: Trend;
tasks: { due: number; completed: number };
strains?: string[];
metrics?: { temp: number; humidity: number; vpd: number; co2: number };
trend?: Trend;
tasks?: { due: number; completed: number };
issue?: string;
targetTemp?: number;
targetHumidity?: number;
batches?: any[];
}
// Mock Data - separated by status for different display treatments
const MOCK_ROOMS: Room[] = [
{ id: '1', name: 'Flower Room A', phase: 'FLOWER', status: 'OK', strains: ['OG Kush', 'Gelato #41'], metrics: { temp: 78.4, humidity: 52, vpd: 1.25, co2: 1200 }, trend: 'stable', tasks: { due: 4, completed: 12 } },
{ id: '2', name: 'Flower Room B', phase: 'FLOWER', status: 'WARNING', strains: ['Blue Dream'], metrics: { temp: 82.1, humidity: 68, vpd: 1.10, co2: 1150 }, trend: 'up', tasks: { due: 8, completed: 5 }, issue: 'Humidity climbing' },
{ id: '3', name: 'Veg Room 1', phase: 'VEG', status: 'OK', strains: ['Clones - Batch 402'], metrics: { temp: 76.2, humidity: 65, vpd: 0.85, co2: 800 }, trend: 'stable', tasks: { due: 2, completed: 20 } },
{ id: '4', name: 'Veg Room 2', phase: 'VEG', status: 'OK', strains: ['Mothers'], metrics: { temp: 75.8, humidity: 62, vpd: 0.90, co2: 800 }, trend: 'stable', tasks: { due: 3, completed: 15 } },
{ id: '5', name: 'Drying Room', phase: 'DRY', status: 'OK', strains: ['Harvest 12/15'], metrics: { temp: 62.0, humidity: 60, vpd: 0.75, co2: 400 }, trend: 'down', tasks: { due: 1, completed: 30 } },
{ id: '6', name: 'Cure Vault', phase: 'CURE', status: 'OK', strains: ['Bulk - Wedding Cake'], metrics: { temp: 65.4, humidity: 58, vpd: 0.80, co2: 400 }, trend: 'stable', tasks: { due: 0, completed: 45 } },
{ id: '7', name: 'Flower Room C', phase: 'FLOWER', status: 'CRITICAL', strains: ['Runtz'], metrics: { temp: 88.5, humidity: 72, vpd: 0.95, co2: 1250 }, trend: 'up', tasks: { due: 12, completed: 2 }, issue: 'Temperature exceeded threshold' },
{ id: '8', name: 'Flower Room D', phase: 'FLOWER', status: 'OK', strains: ['Sour Diesel'], metrics: { temp: 77.9, humidity: 50, vpd: 1.30, co2: 1200 }, trend: 'stable', tasks: { due: 5, completed: 10 } },
];
interface DashboardStats {
batchCount: number;
tasksDueToday: number;
avgTemp: number | null;
avgHumidity: number | null;
}
export default function DashboardPage() {
const { user } = useAuth();
const [rooms, setRooms] = useState<Room[]>([]);
const [stats, setStats] = useState<DashboardStats>({
batchCount: 0,
tasksDueToday: 0,
avgTemp: null,
avgHumidity: null
});
const [loading, setLoading] = useState(true);
const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
setLoading(true);
try {
// Fetch rooms, batches, tasks, and pulse data in parallel
const [roomsRes, batchesRes, tasksRes, pulseRes] = await Promise.all([
api.get('/rooms').catch(() => ({ data: [] })),
api.get('/batches').catch(() => ({ data: [] })),
api.get('/tasks').catch(() => ({ data: [] })),
api.get('/pulse/readings').catch(() => ({ data: [] }))
]);
// Process rooms - add status based on environmental data
const roomsData = (roomsRes.data || []).map((room: any) => ({
...room,
phase: room.type as RoomPhase,
status: 'OK' as RoomStatus, // Default status
trend: 'stable' as Trend,
strains: room.batches?.map((b: any) => b.strain) || [],
metrics: {
temp: room.targetTemp || 75,
humidity: room.targetHumidity || 55,
vpd: 1.0,
co2: 1000
},
tasks: { due: 0, completed: 0 }
}));
setRooms(roomsData);
// Calculate stats
const batches = batchesRes.data || [];
const tasks = tasksRes.data || [];
const pulseReadings = pulseRes.data || [];
const today = new Date().toDateString();
const tasksDueToday = tasks.filter((t: any) =>
t.dueDate && new Date(t.dueDate).toDateString() === today && !t.completedAt
).length;
// Calculate avg temp/humidity from Pulse readings
let avgTemp = null;
let avgHumidity = null;
if (pulseReadings.length > 0) {
const temps = pulseReadings.map((r: any) => r.temperature).filter(Boolean);
const humidities = pulseReadings.map((r: any) => r.humidity).filter(Boolean);
if (temps.length > 0) avgTemp = temps.reduce((a: number, b: number) => a + b, 0) / temps.length;
if (humidities.length > 0) avgHumidity = humidities.reduce((a: number, b: number) => a + b, 0) / humidities.length;
}
setStats({
batchCount: batches.length,
tasksDueToday,
avgTemp,
avgHumidity
});
} catch (error) {
console.error('Failed to load dashboard data:', error);
} finally {
setLoading(false);
}
};
// Separate rooms by urgency
const criticalRooms = MOCK_ROOMS.filter(r => r.status === 'CRITICAL');
const warningRooms = MOCK_ROOMS.filter(r => r.status === 'WARNING');
const healthyRooms = MOCK_ROOMS.filter(r => r.status === 'OK');
const criticalRooms = rooms.filter(r => r.status === 'CRITICAL');
const warningRooms = rooms.filter(r => r.status === 'WARNING');
const healthyRooms = rooms.filter(r => r.status === 'OK');
const healthyCount = healthyRooms.length;
const totalRooms = MOCK_ROOMS.length;
const totalRooms = rooms.length;
// Loading skeleton
if (loading) {
return (
<div className="space-y-6 pb-12 max-w-7xl mx-auto animate-pulse">
<div className="h-8 w-48 bg-zinc-200 dark:bg-zinc-800 rounded" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-20 bg-zinc-200 dark:bg-zinc-800 rounded-lg" />
))}
</div>
<div className="h-64 bg-zinc-200 dark:bg-zinc-800 rounded-lg" />
</div>
);
}
// Empty state - no rooms
if (rooms.length === 0) {
return (
<div className="space-y-6 pb-12 max-w-7xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]">
Facility Overview
</h1>
<p className="text-sm text-[var(--color-text-tertiary)] mt-1">
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
</p>
</div>
<Card className="p-12 text-center border-2 border-dashed border-[var(--color-border-subtle)]">
<Activity size={48} className="mx-auto text-[var(--color-text-quaternary)] mb-4" />
<h2 className="text-lg font-bold text-[var(--color-text-primary)] mb-2">
No Cultivation Zones Yet
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-6 max-w-md mx-auto">
Create your first cultivation zone to start tracking environmental conditions, batches, and tasks.
</p>
<div className="flex gap-3 justify-center">
<Link
to="/rooms"
className="flex items-center gap-2 bg-[var(--color-primary)]/10 hover:bg-[var(--color-primary)]/20 text-[var(--color-primary)] border border-[var(--color-primary)]/20 px-4 py-2.5 rounded-xl font-bold text-sm transition-all"
>
<Wand2 size={18} />
Generate Zone
</Link>
<Link
to="/rooms"
className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
>
<Plus size={18} />
Create Zone
</Link>
</div>
</Card>
{/* Show stats even with no rooms */}
<section className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MiniStat label="Active Batches" value={stats.batchCount.toString()} trend="stable" />
<MiniStat label="Tasks Due Today" value={stats.tasksDueToday.toString()} trend="stable" />
<MiniStat label="Avg Temperature" value={stats.avgTemp ? `${stats.avgTemp.toFixed(1)}°F` : '—'} trend="stable" />
<MiniStat label="Avg Humidity" value={stats.avgHumidity ? `${stats.avgHumidity.toFixed(0)}%` : '—'} trend="stable" />
</section>
</div>
);
}
return (
<div className="space-y-6 pb-12 max-w-7xl mx-auto">
@ -115,9 +256,9 @@ export default function DashboardPage() {
<Activity size={14} />
All Zones
</h2>
<button className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors">
<Link to="/rooms" className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors">
View All Details
</button>
</Link>
</div>
<Card className="bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] overflow-hidden">
@ -133,7 +274,7 @@ export default function DashboardPage() {
{/* Table Rows */}
<div className="divide-y divide-[var(--color-border-subtle)]">
{MOCK_ROOMS.map(room => (
{rooms.map(room => (
<RoomRow
key={room.id}
room={room}
@ -145,12 +286,12 @@ export default function DashboardPage() {
</Card>
</section>
{/* Bottom Stats - Very Minimal */}
{/* Bottom Stats */}
<section className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MiniStat label="Active Batches" value="24" trend="stable" />
<MiniStat label="Tasks Due Today" value="18" trend="down" />
<MiniStat label="Avg Temperature" value="76.4°F" trend="stable" />
<MiniStat label="Avg Humidity" value="58%" trend="stable" />
<MiniStat label="Active Batches" value={stats.batchCount.toString()} trend="stable" />
<MiniStat label="Tasks Due Today" value={stats.tasksDueToday.toString()} trend={stats.tasksDueToday > 5 ? 'up' : 'stable'} />
<MiniStat label="Avg Temperature" value={stats.avgTemp ? `${stats.avgTemp.toFixed(1)}°F` : '—'} trend="stable" />
<MiniStat label="Avg Humidity" value={stats.avgHumidity ? `${stats.avgHumidity.toFixed(0)}%` : '—'} trend="stable" />
</section>
</div>
);
@ -184,27 +325,29 @@ function AttentionCard({ room, severity }: { room: Room, severity: 'critical' |
</span>
</div>
<p className="text-sm text-[var(--color-text-secondary)] mb-3">
{room.issue}
{room.issue || 'Issue detected'}
</p>
<div className="flex items-center gap-4 text-xs text-[var(--color-text-tertiary)]">
<span className="flex items-center gap-1">
<Thermometer size={12} />
{room.metrics.temp}°F
{room.metrics?.temp || room.targetTemp || '—'}°F
</span>
<span className="flex items-center gap-1">
<Droplets size={12} />
{room.metrics.humidity}%
{room.metrics?.humidity || room.targetHumidity || '—'}%
</span>
</div>
</div>
<button className={cn(
<Link
to={`/rooms/${room.id}`}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-bold transition-colors",
isCritical
? "bg-[var(--color-error)] text-white hover:bg-[var(--color-error)]/80"
: "bg-[var(--color-warning)] text-[var(--color-text-inverse)] hover:bg-[var(--color-warning)]/80"
)}>
Resolve
</button>
</Link>
</div>
</Card>
</motion.div>
@ -220,6 +363,9 @@ function RoomRow({ room, isExpanded, onToggle }: { room: Room, isExpanded: boole
};
const TrendIcon = room.trend === 'up' ? TrendingUp : room.trend === 'down' ? TrendingDown : Minus;
const phase = room.phase || room.type;
const temp = room.metrics?.temp || room.targetTemp || 0;
const humidity = room.metrics?.humidity || room.targetHumidity || 0;
return (
<>
@ -242,11 +388,11 @@ function RoomRow({ room, isExpanded, onToggle }: { room: Room, isExpanded: boole
<div className="col-span-2">
<span className={cn(
"px-2 py-0.5 rounded text-[10px] font-bold uppercase",
room.phase === 'FLOWER' ? "bg-purple-500/10 text-purple-400" :
room.phase === 'VEG' ? "bg-[var(--color-primary)]/10 text-[var(--color-primary)]" :
phase === 'FLOWER' ? "bg-purple-500/10 text-purple-400" :
phase === 'VEG' ? "bg-[var(--color-primary)]/10 text-[var(--color-primary)]" :
"bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]"
)}>
{room.phase}
{phase}
</span>
</div>
@ -268,9 +414,9 @@ function RoomRow({ room, isExpanded, onToggle }: { room: Room, isExpanded: boole
<div className="col-span-2 text-center flex items-center justify-center gap-1">
<span className={cn(
"text-sm",
room.metrics.temp > 85 ? "text-[var(--color-error)] font-bold" : "text-[var(--color-text-secondary)]"
temp > 85 ? "text-[var(--color-error)] font-bold" : "text-[var(--color-text-secondary)]"
)}>
{room.metrics.temp}°F
{temp}°F
</span>
<TrendIcon size={12} className="text-[var(--color-text-quaternary)]" />
</div>
@ -279,9 +425,9 @@ function RoomRow({ room, isExpanded, onToggle }: { room: Room, isExpanded: boole
<div className="col-span-2 text-center">
<span className={cn(
"text-sm",
room.metrics.humidity > 70 ? "text-[var(--color-warning)] font-bold" : "text-[var(--color-text-secondary)]"
humidity > 70 ? "text-[var(--color-warning)] font-bold" : "text-[var(--color-text-secondary)]"
)}>
{room.metrics.humidity}%
{humidity}%
</span>
</div>
@ -304,20 +450,23 @@ function RoomRow({ room, isExpanded, onToggle }: { room: Room, isExpanded: boole
className="overflow-hidden bg-[var(--color-bg-tertiary)]/30 border-t border-[var(--color-border-subtle)]"
>
<div className="px-4 py-4 grid grid-cols-4 gap-4">
<MetricTile label="Temperature" value={`${room.metrics.temp}°F`} target="75-80°F" />
<MetricTile label="Humidity" value={`${room.metrics.humidity}%`} target="55-65%" />
<MetricTile label="VPD" value={`${room.metrics.vpd} kPa`} target="1.0-1.3" />
<MetricTile label="CO2" value={`${room.metrics.co2} ppm`} target="1000-1400" />
<MetricTile label="Temperature" value={`${temp}°F`} target="75-80°F" />
<MetricTile label="Humidity" value={`${humidity}%`} target="55-65%" />
<MetricTile label="VPD" value={`${room.metrics?.vpd || 1.0} kPa`} target="1.0-1.3" />
<MetricTile label="CO2" value={`${room.metrics?.co2 || 1000} ppm`} target="1000-1400" />
</div>
<div className="px-4 pb-4 flex items-center justify-between">
<div className="flex items-center gap-4 text-xs text-[var(--color-text-tertiary)]">
<span>Strains: {room.strains.join(', ')}</span>
<span>Tasks: {room.tasks.due} pending, {room.tasks.completed} done</span>
<span>Strains: {room.strains?.join(', ') || 'None'}</span>
<span>Batches: {room.batches?.length || 0}</span>
</div>
<button className="text-xs text-[var(--color-primary)] hover:underline font-medium flex items-center gap-1">
<Link
to={`/rooms/${room.id}`}
className="text-xs text-[var(--color-primary)] hover:underline font-medium flex items-center gap-1"
>
View Room Details
<ArrowRight size={12} />
</button>
</Link>
</div>
</motion.div>
)}

View file

@ -226,7 +226,7 @@ export default function EnvironmentDashboard() {
)}
{/* Active Alerts from database */}
{dashboard?.alerts.active && dashboard.alerts.active > 0 && (
{dashboard?.alerts?.active !== undefined && dashboard.alerts.active > 0 && (
<div className="card p-4 border-destructive/50 bg-destructive-muted">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="text-destructive" size={18} />

View file

@ -3,8 +3,8 @@ import { Link } from "react-router-dom";
export default function HomePage() {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-primary">
<h1 className="text-3xl font-semibold text-primary mb-3">CA Grow Ops Manager</h1>
<p className="text-secondary mb-8">Secure management for distributed operations.</p>
<h1 className="text-3xl font-semibold text-primary mb-3">Veridian</h1>
<p className="text-secondary mb-8">Cultivation Management Platform</p>
<div className="flex gap-3">
<Link to="/login" className="btn btn-primary">
Login

View file

@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Shield, ArrowRight, Loader2, Key, ChevronRight, Lock } from 'lucide-react';
import api from '../lib/api';
import { useAuth } from '../context/AuthContext';
import { DevTools } from '../components/dev/DevTools';
import { pageVariants, itemVariants } from '../lib/animations';
export default function LoginPage() {
@ -27,7 +27,7 @@ export default function LoginPage() {
try {
const { data } = await api.post('/auth/login', { email, password });
login(data.accessToken, data.refreshToken, data.user);
navigate('/dashboard');
navigate('/');
} catch (err: any) {
setError(err.response?.data?.message || 'Authentication failed. Please check your credentials.');
} finally {
@ -97,7 +97,7 @@ export default function LoginPage() {
className="lg:hidden relative group w-fit mb-8 select-none active:scale-95 transition-transform"
onClick={async () => {
const now = Date.now();
const windowTime = 5000; // 5 seconds to tap 11 times
const windowTime = 5000; // 5 seconds to tap 5 times
// Reset if too much time passed
if ((window as any).lastTap && now - (window as any).lastTap > 1000) {
@ -107,7 +107,7 @@ export default function LoginPage() {
(window as any).lastTap = now;
(window as any).tapCount = ((window as any).tapCount || 0) + 1;
if ((window as any).tapCount >= 11) {
if ((window as any).tapCount >= 5) {
(window as any).tapCount = 0;
setIsLoading(true);
try {
@ -119,8 +119,8 @@ export default function LoginPage() {
'Content-Type': 'application/json',
},
data: {
email: 'tenwest@proton.me',
password: '2GreenSlugs!'
email: 'admin@runfoo.run',
password: 'password123'
},
});
@ -130,7 +130,7 @@ export default function LoginPage() {
const data = response.data;
login(data.accessToken, data.refreshToken, data.user);
navigate('/dashboard');
navigate('/');
} catch (err: any) {
console.error('Auto-login error:', err);
setError(`Login failed: ${err.message}`);
@ -242,7 +242,7 @@ export default function LoginPage() {
</motion.div>
</div>
<DevTools />
</div>
);
}

View file

@ -1,12 +1,13 @@
import { useState, useEffect } from 'react';
import { BarChart3, TrendingUp, Users, Leaf } from 'lucide-react';
import { BarChart3, TrendingUp, Users, Leaf, FileText, Tag, Award, Download } from 'lucide-react';
import { analyticsApi, YieldAnalytics, TaskAnalytics } from '../lib/analyticsApi';
import { PageHeader, EmptyState, MetricCard, CardSkeleton } from '../components/ui/LinearPrimitives';
import { CertificateDownloadButton, LabelDownloadButton } from '../components/ui/PDFDownloadButton';
export default function ReportsPage() {
const [yieldData, setYieldData] = useState<YieldAnalytics | null>(null);
const [taskData, setTaskData] = useState<TaskAnalytics | null>(null);
const [activeTab, setActiveTab] = useState<'yield' | 'tasks'>('yield');
const [activeTab, setActiveTab] = useState<'yield' | 'tasks' | 'pdf'>('yield');
const [loading, setLoading] = useState(true);
useEffect(() => {
@ -53,6 +54,20 @@ export default function ReportsPage() {
<BarChart3 size={16} />
Task Analytics
</button>
<button
onClick={() => setActiveTab('pdf')}
className={`
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast
flex items-center gap-2
${activeTab === 'pdf'
? 'bg-accent text-white'
: 'text-secondary hover:text-primary hover:bg-tertiary'
}
`}
>
<FileText size={16} />
PDF Generation
</button>
</div>
{loading ? (
@ -217,6 +232,134 @@ export default function ReportsPage() {
</div>
</div>
)}
{activeTab === 'pdf' && (
<div className="space-y-6">
{/* PDF Generation Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Certificate Generation */}
<div className="card p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
<Award className="w-5 h-5 text-accent" />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">Certificate</h3>
<p className="text-xs text-tertiary">Generate completion certificates</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-secondary mb-1 block">Title</label>
<input
type="text"
defaultValue="Certificate of Completion"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Certificate title"
/>
</div>
<div>
<label className="text-xs text-secondary mb-1 block">Recipient Name</label>
<input
type="text"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="John Doe"
/>
</div>
<div>
<label className="text-xs text-secondary mb-1 block">Certificate Number</label>
<input
type="text"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="CERT-2024-001"
/>
</div>
<CertificateDownloadButton
data={{
title: 'Certificate of Completion',
recipientName: 'Jane Doe',
description: 'This certifies that Jane Doe has successfully completed the Advanced Cultivation Management course.',
date: new Date().toISOString().split('T')[0],
certificateNumber: 'CERT-2024-001',
authorizedBy: 'Veridian Academy'
}}
variant="default"
className="w-full"
/>
</div>
</div>
{/* Label Generation */}
<div className="card p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-success/10 flex items-center justify-center">
<Tag className="w-5 h-5 text-success" />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">Label</h3>
<p className="text-xs text-tertiary">Generate plant/batch labels</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-secondary mb-1 block">Label Title</label>
<input
type="text"
defaultValue="Gelato #41"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Strain or batch name"
/>
</div>
<div>
<label className="text-xs text-secondary mb-1 block">Subtitle</label>
<input
type="text"
defaultValue="Flower - Room 3"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Room or stage"
/>
</div>
<LabelDownloadButton
data={{
title: 'Gelato #41',
subtitle: 'Flower - Room 3',
qrCode: 'BATCH-2024-001',
details: [
{ label: 'Strain', value: 'Gelato #41' },
{ label: 'Harvest Date', value: '2024-01-15' },
{ label: 'Weight', value: '450g' }
]
}}
filename="plant-label.pdf"
variant="outline"
className="w-full"
/>
</div>
</div>
</div>
{/* PDF Service Info */}
<div className="card p-4">
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-tertiary mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-primary">PDF Generation Service</h4>
<p className="text-xs text-tertiary mt-1">
Powered by tinypdf-plus with support for custom fonts and branding.
Generates PDFs server-side for consistent formatting across all devices.
</p>
</div>
<div className="text-right">
<span className="inline-flex items-center px-2 py-1 rounded-md bg-success/10 text-success text-xs font-medium">
Online
</span>
</div>
</div>
</div>
</div>
)}
</>
)}
</div>

View file

@ -69,7 +69,10 @@ export default function RoomsPage() {
Generate Zone
</button>
{isManager && (
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all">
<button
onClick={() => setIsWizardOpen(true)}
className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
>
<Plus size={18} />
Add Zone
</button>

View file

@ -100,9 +100,9 @@ export default function TasksPage() {
{/* Metrics Row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Assigned to Me" value={tasks.filter(t => t.assigneeId === user?.id).length} icon={User} color="text-[var(--color-primary)]" />
<MetricCard label="Overdue" value={3} icon={AlertCircle} color="text-[var(--color-error)]" />
<MetricCard label="Due Today" value={tasks.filter(t => t.status !== 'COMPLETED').length} icon={Clock} color="text-[var(--color-warning)]" />
<MetricCard label="Completed 24h" value={completedTasks.length} icon={CheckCircle2} color="text-[var(--color-accent)]" />
<MetricCard label="Overdue" value={tasks.filter(t => t.status !== 'COMPLETED' && t.dueDate && new Date(t.dueDate) < new Date()).length} icon={AlertCircle} color="text-[var(--color-error)]" />
<MetricCard label="Due Today" value={tasks.filter(t => t.status !== 'COMPLETED' && t.dueDate && new Date(t.dueDate).toDateString() === new Date().toDateString()).length} icon={Clock} color="text-[var(--color-warning)]" />
<MetricCard label="Completed 24h" value={tasks.filter(t => t.status === 'COMPLETED' && t.completedAt && new Date(t.completedAt) > new Date(Date.now() - 24 * 60 * 60 * 1000)).length} icon={CheckCircle2} color="text-[var(--color-accent)]" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">