Compare commits
53 commits
main
...
feat/aspir
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fe22f03fb | ||
|
|
eca58ecbc2 | ||
|
|
d2abe033f2 | ||
|
|
58d38aef8a | ||
|
|
e7d23f4c7f | ||
|
|
f2dc7526e7 | ||
|
|
555a22b846 | ||
|
|
458913bdf8 | ||
|
|
7607dff622 | ||
|
|
3a62e94ad8 | ||
|
|
e8babfc2eb | ||
|
|
875ae344f8 | ||
|
|
08992257bf | ||
|
|
3b9da95fd9 | ||
|
|
3fc1f6cc4e | ||
|
|
210cd16bbe | ||
|
|
95250185d0 | ||
|
|
6c91d4cd42 | ||
|
|
f0db9d5e5f | ||
|
|
bf2fbe9b19 | ||
|
|
1f7f722238 | ||
|
|
38ddfb00e3 | ||
|
|
6bdc23b9d3 | ||
|
|
f6b9299d00 | ||
|
|
7a19798c48 | ||
|
|
4894679357 | ||
|
|
978286606d | ||
|
|
7c8964180c | ||
|
|
9fe6823508 | ||
|
|
d8f384d44a | ||
|
|
5aed125a60 | ||
|
|
80a1d87cac | ||
|
|
d2151c8ee1 | ||
|
|
5063d95477 | ||
|
|
3673509a87 | ||
|
|
dcad331f48 | ||
|
|
4e8c9fd140 | ||
|
|
908c82916d | ||
|
|
4bdbfc82ca | ||
|
|
f4def70f24 | ||
|
|
1837830a11 | ||
|
|
835c062c88 | ||
|
|
3aa0277ab7 | ||
|
|
c4bfd6126d | ||
|
|
c7d1bfeb99 | ||
|
|
7386b5c6c5 | ||
|
|
8ed82cfab6 | ||
|
|
8b43744f4c | ||
|
|
fa9650d0b8 | ||
|
|
df208548f5 | ||
|
|
b39cd90cf1 | ||
|
|
da32c67300 | ||
|
|
63d0e4ee2d |
|
|
@ -1,7 +1,7 @@
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
# Install OpenSSL for Prisma
|
# Install OpenSSL and Git for Prisma and GitHub dependencies
|
||||||
RUN apk add --no-cache openssl
|
RUN apk add --no-cache openssl git
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -10,17 +10,17 @@ COPY package*.json ./
|
||||||
# Copy prisma directory if it exists, otherwise we'll handle it
|
# Copy prisma directory if it exists, otherwise we'll handle it
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (CACHE BUST: 2025-01-08)
|
||||||
RUN npm install
|
RUN npm install && echo "Cache bust: tinypdf-plus updated with dist"
|
||||||
|
|
||||||
# Copy source
|
# Copy source (CACHE BUST: 2025-01-08-2)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma Client
|
# Generate Prisma Client
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build TypeScript
|
# Build TypeScript (CACHE BUST: 2025-01-08-3)
|
||||||
RUN npm run build
|
RUN npm run build && echo "Build complete"
|
||||||
|
|
||||||
# Production image
|
# Production image
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
@ -41,4 +41,4 @@ USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
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
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "ca-grow-ops-backend",
|
"name": "ca-grow-ops-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "dist/server.js",
|
"main": "dist/src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"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",
|
"dev": "ts-node-dev --transpile-only src/server.ts",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"lint": "eslint src/**/*.ts",
|
"lint": "eslint src/**/*.ts",
|
||||||
|
|
@ -13,9 +13,10 @@
|
||||||
"seed:all": "npx prisma db seed && node prisma/seed-demo.js"
|
"seed:all": "npx prisma db seed && node prisma/seed-demo.js"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "node dist/prisma/seed.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/jwt": "^7.2.4",
|
"@fastify/jwt": "^7.2.4",
|
||||||
"@fastify/multipart": "^8.0.0",
|
"@fastify/multipart": "^8.0.0",
|
||||||
"@fastify/websocket": "^8.3.1",
|
"@fastify/websocket": "^8.3.1",
|
||||||
|
|
@ -29,10 +30,10 @@
|
||||||
"fastify-plugin": "^4.5.0",
|
"fastify-plugin": "^4.5.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"sharp": "^0.33.0",
|
"sharp": "^0.33.0",
|
||||||
|
"tinypdf-plus": "https://github.com/fullsizemalt/tinypdf-plus.git",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
|
|
@ -42,4 +43,4 @@
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
111
backend/prisma/map-pulse.ts
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { PrismaClient, RoomType, SectionType } from '@prisma/client';
|
import { PrismaClient, RoomType, SectionType } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
@ -56,22 +57,42 @@ async function main() {
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: ownerEmail,
|
email: ownerEmail,
|
||||||
passwordHash: 'password123',
|
passwordHash: await bcrypt.hash('password123', 10),
|
||||||
name: 'Travis',
|
name: 'Travis',
|
||||||
role: 'OWNER', // Enum fallback
|
role: 'OWNER', // Enum fallback
|
||||||
roleId: ownerRole?.id,
|
roleId: ownerRole?.id,
|
||||||
rate: 100.00
|
rate: 100.00
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log('Created Owner: Travis (admin@runfoo.com)');
|
console.log('Created Owner: Travis (admin@runfoo.run)');
|
||||||
} else {
|
} else {
|
||||||
// Update existing owner to have roleId if missing
|
// Always ensure password is properly hashed and role is set
|
||||||
if (!existingOwner.roleId && ownerRole) {
|
await prisma.user.update({
|
||||||
await prisma.user.update({
|
where: { email: ownerEmail },
|
||||||
where: { email: ownerEmail },
|
data: {
|
||||||
data: { roleId: ownerRole.id, name: 'Travis' }
|
passwordHash: await bcrypt.hash('password123', 10),
|
||||||
});
|
roleId: ownerRole?.id || existingOwner.roleId,
|
||||||
console.log('Updated Owner permissions');
|
name: 'Travis'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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.');
|
console.log('Seeding complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { hashPassword } from '../utils/password';
|
||||||
|
|
||||||
export const getUsers = async (request: FastifyRequest, reply: FastifyReply) => {
|
export const getUsers = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
// ... existing implementation ...
|
// ... existing implementation ...
|
||||||
|
|
@ -57,3 +58,59 @@ export const updateUser = async (request: FastifyRequest, reply: FastifyReply) =
|
||||||
return reply.code(500).send({ message: 'Failed to update user' });
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export async function websocketPlugin(fastify: FastifyInstance) {
|
||||||
const socket = connection.socket;
|
const socket = connection.socket;
|
||||||
|
|
||||||
clients.set(clientId, socket);
|
clients.set(clientId, socket);
|
||||||
fastify.log.info(`WebSocket client connected: ${clientId}`);
|
fastify.log.debug(`WebSocket client connected: ${clientId}`);
|
||||||
|
|
||||||
// Send welcome message
|
// Send welcome message
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
|
|
@ -53,7 +53,7 @@ export async function websocketPlugin(fastify: FastifyInstance) {
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
clients.delete(clientId);
|
clients.delete(clientId);
|
||||||
fastify.log.info(`WebSocket client disconnected: ${clientId}`);
|
fastify.log.debug(`WebSocket client disconnected: ${clientId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (error: Error) => {
|
socket.on('error', (error: Error) => {
|
||||||
|
|
|
||||||
94
backend/src/routes/branding.routes.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
131
backend/src/routes/pdf.routes.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,8 @@ import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
// Storage base path - configurable via env
|
// Storage base path - uses mounted volume for persistence
|
||||||
const STORAGE_PATH = process.env.STORAGE_PATH || '/tmp/ca-grow-ops-manager/photos';
|
const STORAGE_PATH = process.env.STORAGE_PATH || '/app/photos';
|
||||||
|
|
||||||
// Image size configurations per spec
|
// Image size configurations per spec
|
||||||
const IMAGE_SIZES = {
|
const IMAGE_SIZES = {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { FastifyInstance } from 'fastify';
|
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) {
|
export async function userRoutes(server: FastifyInstance) {
|
||||||
server.get('/', getUsers);
|
server.get('/', getUsers);
|
||||||
|
server.post('/', createUser);
|
||||||
server.patch('/:id', updateUser);
|
server.patch('/:id', updateUser);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,7 @@ server.register(cors, {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manual OPTIONS handler as fallback
|
// Manual OPTIONS handler as fallback
|
||||||
server.options('/*', async (request, reply) => {
|
// Manual OPTIONS handler removed as it conflicts with @fastify/cors plugin
|
||||||
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();
|
|
||||||
});
|
|
||||||
server.register(prismaPlugin);
|
server.register(prismaPlugin);
|
||||||
server.register(jwt, {
|
server.register(jwt, {
|
||||||
secret: process.env.JWT_SECRET || 'supersecret'
|
secret: process.env.JWT_SECRET || 'supersecret'
|
||||||
|
|
@ -78,6 +72,12 @@ server.register(messagingRoutes, { prefix: '/api/messaging' });
|
||||||
import { plantRoutes } from './routes/plants.routes';
|
import { plantRoutes } from './routes/plants.routes';
|
||||||
server.register(plantRoutes, { prefix: '/api/plants' });
|
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
|
// Phase 10: Compliance
|
||||||
import { auditRoutes } from './routes/audit.routes';
|
import { auditRoutes } from './routes/audit.routes';
|
||||||
import { documentRoutes } from './routes/documents.routes';
|
import { documentRoutes } from './routes/documents.routes';
|
||||||
|
|
|
||||||
103
backend/src/services/branding.service.ts
Normal 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();
|
||||||
283
backend/src/services/pdf.service.ts
Normal 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()
|
||||||
|
|
@ -6,14 +6,14 @@
|
||||||
"ES2020"
|
"ES2020"
|
||||||
],
|
],
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*"
|
"src/**/*",
|
||||||
|
"prisma/**/*"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|
|
||||||
41
deploy.sh
|
|
@ -1,7 +1,7 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# CA Grow Ops Manager - Automated Deployment Script
|
# CA Grow Ops Manager - Automated Deployment Script
|
||||||
# Usage: ./deploy.sh [env]
|
# Usage: ./deploy.sh [env] [branch]
|
||||||
# Environments: test (default), prod
|
# Environments: test (default), prod, preview
|
||||||
|
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
|
|
@ -12,6 +12,10 @@ ENV=${1:-test}
|
||||||
APP_NAME="ca-grow-ops-manager"
|
APP_NAME="ca-grow-ops-manager"
|
||||||
REPO_URL="https://git.runfoo.run/malty/ca-grow-ops-manager.git"
|
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
|
# Define Environment Variables
|
||||||
case "$ENV" in
|
case "$ENV" in
|
||||||
test)
|
test)
|
||||||
|
|
@ -28,8 +32,15 @@ case "$ENV" in
|
||||||
PORT="8010"
|
PORT="8010"
|
||||||
ENV_DISPLAY="🔴 PROD (Tangible-Aacorn)"
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -39,6 +50,7 @@ echo "=============================================="
|
||||||
echo "Target: $ENV_DISPLAY"
|
echo "Target: $ENV_DISPLAY"
|
||||||
echo "Host: $USER@$HOST"
|
echo "Host: $USER@$HOST"
|
||||||
echo "Path: $DEPLOY_PATH"
|
echo "Path: $DEPLOY_PATH"
|
||||||
|
echo "Branch: $BRANCH"
|
||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|
@ -70,7 +82,7 @@ echo ""
|
||||||
|
|
||||||
# Step 2: Push to Forgejo (Local)
|
# Step 2: Push to Forgejo (Local)
|
||||||
echo -e "${BLUE}Step 2: Pushing code to Forgejo...${NC}"
|
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 -e "${GREEN}✓ Code pushed${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|
@ -85,10 +97,10 @@ echo -e "${BLUE}Step 4: Syncing repository on $HOST...${NC}"
|
||||||
ssh "$USER@$HOST" "
|
ssh "$USER@$HOST" "
|
||||||
if [ ! -d $DEPLOY_PATH/.git ]; then
|
if [ ! -d $DEPLOY_PATH/.git ]; then
|
||||||
echo ' Cloning repository...'
|
echo ' Cloning repository...'
|
||||||
git clone $REPO_URL $DEPLOY_PATH
|
git clone -b $BRANCH $REPO_URL $DEPLOY_PATH
|
||||||
else
|
else
|
||||||
echo ' Pulling latest changes...'
|
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
|
fi
|
||||||
"
|
"
|
||||||
echo -e "${GREEN}✓ Code synced${NC}"
|
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
|
if [ "$HAS_ENV" = "no" ]; then
|
||||||
echo -e "${YELLOW}Creating new environment file on remote...${NC}"
|
echo -e "${YELLOW}Creating new environment file on remote...${NC}"
|
||||||
DB_PASSWORD=$(openssl rand -base64 32)
|
# Use hex encoding to avoid special characters that break database URLs
|
||||||
JWT_SECRET=$(openssl rand -base64 64)
|
DB_PASSWORD=$(openssl rand -hex 24)
|
||||||
|
JWT_SECRET=$(openssl rand -hex 48)
|
||||||
|
|
||||||
ssh "$USER@$HOST" "cat > $ENV_FILE << EOF
|
ssh "$USER@$HOST" "cat > $ENV_FILE << EOF
|
||||||
# Database
|
# Database
|
||||||
|
|
@ -113,6 +126,7 @@ JWT_SECRET=${JWT_SECRET}
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
PORT=${PORT}
|
||||||
EOF"
|
EOF"
|
||||||
echo -e "${GREEN}✓ Environment file created${NC}"
|
echo -e "${GREEN}✓ Environment file created${NC}"
|
||||||
echo -e "${YELLOW}IMPORTANT: New secrets generated on $HOST.${NC}"
|
echo -e "${YELLOW}IMPORTANT: New secrets generated on $HOST.${NC}"
|
||||||
|
|
@ -125,14 +139,17 @@ echo ""
|
||||||
echo -e "${BLUE}Step 6: Deploying services...${NC}"
|
echo -e "${BLUE}Step 6: Deploying services...${NC}"
|
||||||
ssh "$USER@$HOST" "
|
ssh "$USER@$HOST" "
|
||||||
cd $DEPLOY_PATH
|
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...'
|
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...'
|
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 -e "${GREEN}✓ Services deployed${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
10
docker-compose.preview.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "${PORT}:80"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
go2rtc:
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
|
@ -30,6 +30,7 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
container_name: veridian-preview-backend
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|
@ -56,6 +57,8 @@ services:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
volumes:
|
||||||
|
- photos_data:/app/photos
|
||||||
|
|
||||||
go2rtc:
|
go2rtc:
|
||||||
image: alexxit/go2rtc:latest
|
image: alexxit/go2rtc:latest
|
||||||
|
|
@ -93,6 +96,7 @@ services:
|
||||||
- go2rtc
|
- go2rtc
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
container_name: veridian-preview-frontend
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|
@ -119,3 +123,4 @@ networks:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
photos_data:
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 307 KiB |
|
|
@ -1,16 +1,21 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="default">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<head>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<meta charset="UTF-8" />
|
<link
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Merriweather:wght@300;400;700;900&display=swap"
|
||||||
<meta name="theme-color" content="#10b981" />
|
rel="stylesheet">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="viewport"
|
||||||
<meta name="apple-mobile-web-app-title" content="Veridian" />
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<meta name="theme-color" content="#10b981" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<title>Veridian - Cultivation Platform</title>
|
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,13 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# DNS resolver for Docker
|
||||||
|
resolver 127.0.0.11 valid=30s;
|
||||||
|
|
||||||
# API proxy
|
# API proxy
|
||||||
location /api {
|
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_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
|
|
@ -30,9 +34,22 @@ server {
|
||||||
add_header Content-Disposition "attachment; filename=visitorkiosk.apk";
|
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
|
# Frontend SPA routing
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
|
|
|
||||||
BIN
frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 307 KiB |
|
|
@ -1,55 +1,28 @@
|
||||||
const CACHE_NAME = 'wolfpack-kiosk-v1';
|
const CACHE_NAME = 'veridian-v2';
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/kiosk',
|
'/',
|
||||||
'/badges',
|
|
||||||
'/manifest.json'
|
'/manifest.json'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install event - cache static assets
|
// Install event - skip waiting immediately to invalidate old cache
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
return cache.addAll(STATIC_ASSETS);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate event - clean up old caches
|
// Activate event - clean up ALL old caches
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches.keys().then((cacheNames) => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames
|
cacheNames.map((name) => caches.delete(name))
|
||||||
.filter((name) => name !== CACHE_NAME)
|
|
||||||
.map((name) => caches.delete(name))
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
self.clients.claim();
|
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) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
// Skip non-GET requests
|
// Just pass through to network
|
||||||
if (event.request.method !== 'GET') return;
|
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);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
208
frontend/src/components/CreateBatchModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/components/DesignSwitch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,11 +9,12 @@ import { CommandPalette } from './ui/CommandPalette';
|
||||||
import { SessionTimeoutWarning } from './ui/SessionTimeoutWarning';
|
import { SessionTimeoutWarning } from './ui/SessionTimeoutWarning';
|
||||||
import { PageTitleUpdater } from '../hooks/usePageTitle';
|
import { PageTitleUpdater } from '../hooks/usePageTitle';
|
||||||
import AnnouncementBanner from './AnnouncementBanner';
|
import AnnouncementBanner from './AnnouncementBanner';
|
||||||
import { DevTools } from './dev/DevTools';
|
|
||||||
import { Breadcrumbs } from './ui/Breadcrumbs';
|
import { Breadcrumbs } from './ui/Breadcrumbs';
|
||||||
import { pageVariants } from '../lib/animations';
|
import { pageVariants } from '../lib/animations';
|
||||||
import { Search, Bell, Settings, Filter, ChevronDown } from 'lucide-react';
|
import { Search, Bell, Settings, Filter, ChevronDown } from 'lucide-react';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
|
|
||||||
import { UserMenu } from './layout/UserMenu';
|
import { UserMenu } from './layout/UserMenu';
|
||||||
import { NotificationBell } from './notifications/NotificationBell';
|
import { NotificationBell } from './notifications/NotificationBell';
|
||||||
|
|
||||||
|
|
@ -55,7 +56,10 @@ export default function Layout() {
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||||
{/* Topbar - Search, Global Filters, Vitals */}
|
{/* 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">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
{/* Facility Switcher / Filter */}
|
{/* 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">
|
<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 */}
|
{/* System Utilities */}
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<SessionTimeoutWarning />
|
<SessionTimeoutWarning />
|
||||||
<DevTools />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 777 Wolfpack UI Primitives
|
* Veridian UI Primitives
|
||||||
* High-performance, high-density components for operational management.
|
* High-performance, high-density components for operational management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -230,7 +230,7 @@ export function StatusBadge({ status, label, className }: StatusBadgeProps) {
|
||||||
|
|
||||||
// Skeleton loader
|
// Skeleton loader
|
||||||
export function Skeleton({ className }: { className?: string }) {
|
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
|
// Card skeleton
|
||||||
|
|
|
||||||
180
frontend/src/components/ui/PDFDownloadButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/context/ThemeContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
/* ============================================
|
/* ============================================
|
||||||
CSS Custom Properties (Design Tokens)
|
CSS Custom Properties (Design Tokens)
|
||||||
============================================ */
|
============================================ */
|
||||||
:root {
|
:root,
|
||||||
|
[data-theme="default"] {
|
||||||
/* Font families */
|
/* Font families */
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
--font-display: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
--font-display: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
@ -777,4 +778,106 @@
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* ===== 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%;
|
||||||
|
}
|
||||||
|
|
|
||||||
144
frontend/src/lib/branding.ts
Normal 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
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,15 @@ import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
|
|
||||||
// Initialize i18n
|
// Initialize i18n
|
||||||
import './lib/i18n'
|
import './lib/i18n'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useToast } from '../context/ToastContext';
|
||||||
import BatchTransitionModal from '../components/BatchTransitionModal';
|
import BatchTransitionModal from '../components/BatchTransitionModal';
|
||||||
import WeightLogModal from '../components/WeightLogModal';
|
import WeightLogModal from '../components/WeightLogModal';
|
||||||
import CreateTaskModal from '../components/tasks/CreateTaskModal';
|
import CreateTaskModal from '../components/tasks/CreateTaskModal';
|
||||||
|
import CreateBatchModal from '../components/CreateBatchModal';
|
||||||
import IPMScheduleModal from '../components/IPMScheduleModal';
|
import IPMScheduleModal from '../components/IPMScheduleModal';
|
||||||
import ScoutingModal from '../components/ipm/ScoutingModal';
|
import ScoutingModal from '../components/ipm/ScoutingModal';
|
||||||
import { DataTable, Column } from '../components/ui/DataTable';
|
import { DataTable, Column } from '../components/ui/DataTable';
|
||||||
|
|
@ -63,6 +64,7 @@ export default function BatchesPage() {
|
||||||
const [createTaskBatch, setCreateTaskBatch] = useState<Batch | null>(null);
|
const [createTaskBatch, setCreateTaskBatch] = useState<Batch | null>(null);
|
||||||
const [ipmBatch, setIpmBatch] = useState<Batch | null>(null);
|
const [ipmBatch, setIpmBatch] = useState<Batch | null>(null);
|
||||||
const [scoutingBatch, setScoutingBatch] = useState<Batch | null>(null);
|
const [scoutingBatch, setScoutingBatch] = useState<Batch | null>(null);
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBatches();
|
fetchBatches();
|
||||||
|
|
@ -205,7 +207,10 @@ export default function BatchesPage() {
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<Plus size={18} />
|
||||||
New Batch
|
New Batch
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -304,6 +309,14 @@ export default function BatchesPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<CreateBatchModal
|
||||||
|
isOpen={isCreateOpen}
|
||||||
|
onClose={() => setIsCreateOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
fetchBatches();
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useAuth } from '../context/AuthContext';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -13,11 +14,13 @@ import {
|
||||||
Droplets,
|
Droplets,
|
||||||
Leaf,
|
Leaf,
|
||||||
Activity,
|
Activity,
|
||||||
MoreHorizontal
|
Plus,
|
||||||
|
Wand2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card } from '../components/ui/card';
|
import { Card } from '../components/ui/card';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import api from '../lib/api';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type RoomStatus = 'OK' | 'WARNING' | 'CRITICAL';
|
type RoomStatus = 'OK' | 'WARNING' | 'CRITICAL';
|
||||||
|
|
@ -27,38 +30,176 @@ type Trend = 'up' | 'down' | 'stable';
|
||||||
interface Room {
|
interface Room {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
phase: RoomPhase;
|
type: string;
|
||||||
|
phase?: RoomPhase;
|
||||||
status: RoomStatus;
|
status: RoomStatus;
|
||||||
strains: string[];
|
strains?: string[];
|
||||||
metrics: { temp: number; humidity: number; vpd: number; co2: number };
|
metrics?: { temp: number; humidity: number; vpd: number; co2: number };
|
||||||
trend: Trend;
|
trend?: Trend;
|
||||||
tasks: { due: number; completed: number };
|
tasks?: { due: number; completed: number };
|
||||||
issue?: string;
|
issue?: string;
|
||||||
|
targetTemp?: number;
|
||||||
|
targetHumidity?: number;
|
||||||
|
batches?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock Data - separated by status for different display treatments
|
interface DashboardStats {
|
||||||
const MOCK_ROOMS: Room[] = [
|
batchCount: number;
|
||||||
{ 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 } },
|
tasksDueToday: number;
|
||||||
{ 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' },
|
avgTemp: number | null;
|
||||||
{ 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 } },
|
avgHumidity: number | null;
|
||||||
{ 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 } },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user } = useAuth();
|
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);
|
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
|
// Separate rooms by urgency
|
||||||
const criticalRooms = MOCK_ROOMS.filter(r => r.status === 'CRITICAL');
|
const criticalRooms = rooms.filter(r => r.status === 'CRITICAL');
|
||||||
const warningRooms = MOCK_ROOMS.filter(r => r.status === 'WARNING');
|
const warningRooms = rooms.filter(r => r.status === 'WARNING');
|
||||||
const healthyRooms = MOCK_ROOMS.filter(r => r.status === 'OK');
|
const healthyRooms = rooms.filter(r => r.status === 'OK');
|
||||||
|
|
||||||
const healthyCount = healthyRooms.length;
|
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 (
|
return (
|
||||||
<div className="space-y-6 pb-12 max-w-7xl mx-auto">
|
<div className="space-y-6 pb-12 max-w-7xl mx-auto">
|
||||||
|
|
@ -115,9 +256,9 @@ export default function DashboardPage() {
|
||||||
<Activity size={14} />
|
<Activity size={14} />
|
||||||
All Zones
|
All Zones
|
||||||
</h2>
|
</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
|
View All Details
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] overflow-hidden">
|
<Card className="bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] overflow-hidden">
|
||||||
|
|
@ -133,7 +274,7 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
{/* Table Rows */}
|
{/* Table Rows */}
|
||||||
<div className="divide-y divide-[var(--color-border-subtle)]">
|
<div className="divide-y divide-[var(--color-border-subtle)]">
|
||||||
{MOCK_ROOMS.map(room => (
|
{rooms.map(room => (
|
||||||
<RoomRow
|
<RoomRow
|
||||||
key={room.id}
|
key={room.id}
|
||||||
room={room}
|
room={room}
|
||||||
|
|
@ -145,12 +286,12 @@ export default function DashboardPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Bottom Stats - Very Minimal */}
|
{/* Bottom Stats */}
|
||||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<MiniStat label="Active Batches" value="24" trend="stable" />
|
<MiniStat label="Active Batches" value={stats.batchCount.toString()} trend="stable" />
|
||||||
<MiniStat label="Tasks Due Today" value="18" trend="down" />
|
<MiniStat label="Tasks Due Today" value={stats.tasksDueToday.toString()} trend={stats.tasksDueToday > 5 ? 'up' : 'stable'} />
|
||||||
<MiniStat label="Avg Temperature" value="76.4°F" trend="stable" />
|
<MiniStat label="Avg Temperature" value={stats.avgTemp ? `${stats.avgTemp.toFixed(1)}°F` : '—'} trend="stable" />
|
||||||
<MiniStat label="Avg Humidity" value="58%" trend="stable" />
|
<MiniStat label="Avg Humidity" value={stats.avgHumidity ? `${stats.avgHumidity.toFixed(0)}%` : '—'} trend="stable" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -184,27 +325,29 @@ function AttentionCard({ room, severity }: { room: Room, severity: 'critical' |
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--color-text-secondary)] mb-3">
|
<p className="text-sm text-[var(--color-text-secondary)] mb-3">
|
||||||
{room.issue}
|
{room.issue || 'Issue detected'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 text-xs text-[var(--color-text-tertiary)]">
|
<div className="flex items-center gap-4 text-xs text-[var(--color-text-tertiary)]">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Thermometer size={12} />
|
<Thermometer size={12} />
|
||||||
{room.metrics.temp}°F
|
{room.metrics?.temp || room.targetTemp || '—'}°F
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Droplets size={12} />
|
<Droplets size={12} />
|
||||||
{room.metrics.humidity}%
|
{room.metrics?.humidity || room.targetHumidity || '—'}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className={cn(
|
<Link
|
||||||
"px-3 py-1.5 rounded-lg text-xs font-bold transition-colors",
|
to={`/rooms/${room.id}`}
|
||||||
isCritical
|
className={cn(
|
||||||
? "bg-[var(--color-error)] text-white hover:bg-[var(--color-error)]/80"
|
"px-3 py-1.5 rounded-lg text-xs font-bold transition-colors",
|
||||||
: "bg-[var(--color-warning)] text-[var(--color-text-inverse)] hover:bg-[var(--color-warning)]/80"
|
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
|
Resolve
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -242,11 +388,11 @@ function RoomRow({ room, isExpanded, onToggle }: { room: Room, isExpanded: boole
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"px-2 py-0.5 rounded text-[10px] font-bold uppercase",
|
"px-2 py-0.5 rounded text-[10px] font-bold uppercase",
|
||||||
room.phase === 'FLOWER' ? "bg-purple-500/10 text-purple-400" :
|
phase === 'FLOWER' ? "bg-purple-500/10 text-purple-400" :
|
||||||
room.phase === 'VEG' ? "bg-[var(--color-primary)]/10 text-[var(--color-primary)]" :
|
phase === 'VEG' ? "bg-[var(--color-primary)]/10 text-[var(--color-primary)]" :
|
||||||
"bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]"
|
"bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]"
|
||||||
)}>
|
)}>
|
||||||
{room.phase}
|
{phase}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="col-span-2 text-center flex items-center justify-center gap-1">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-sm",
|
"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>
|
</span>
|
||||||
<TrendIcon size={12} className="text-[var(--color-text-quaternary)]" />
|
<TrendIcon size={12} className="text-[var(--color-text-quaternary)]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -279,9 +425,9 @@ function RoomRow({ room, isExpanded, onToggle }: { room: Room, isExpanded: boole
|
||||||
<div className="col-span-2 text-center">
|
<div className="col-span-2 text-center">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-sm",
|
"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>
|
</span>
|
||||||
</div>
|
</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)]"
|
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">
|
<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="Temperature" value={`${temp}°F`} target="75-80°F" />
|
||||||
<MetricTile label="Humidity" value={`${room.metrics.humidity}%`} target="55-65%" />
|
<MetricTile label="Humidity" value={`${humidity}%`} target="55-65%" />
|
||||||
<MetricTile label="VPD" value={`${room.metrics.vpd} kPa`} target="1.0-1.3" />
|
<MetricTile label="VPD" value={`${room.metrics?.vpd || 1.0} kPa`} target="1.0-1.3" />
|
||||||
<MetricTile label="CO2" value={`${room.metrics.co2} ppm`} target="1000-1400" />
|
<MetricTile label="CO2" value={`${room.metrics?.co2 || 1000} ppm`} target="1000-1400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 pb-4 flex items-center justify-between">
|
<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)]">
|
<div className="flex items-center gap-4 text-xs text-[var(--color-text-tertiary)]">
|
||||||
<span>Strains: {room.strains.join(', ')}</span>
|
<span>Strains: {room.strains?.join(', ') || 'None'}</span>
|
||||||
<span>Tasks: {room.tasks.due} pending, {room.tasks.completed} done</span>
|
<span>Batches: {room.batches?.length || 0}</span>
|
||||||
</div>
|
</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
|
View Room Details
|
||||||
<ArrowRight size={12} />
|
<ArrowRight size={12} />
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export default function EnvironmentDashboard() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Alerts from database */}
|
{/* 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="card p-4 border-destructive/50 bg-destructive-muted">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<AlertTriangle className="text-destructive" size={18} />
|
<AlertTriangle className="text-destructive" size={18} />
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { Link } from "react-router-dom";
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-primary">
|
<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>
|
<h1 className="text-3xl font-semibold text-primary mb-3">Veridian</h1>
|
||||||
<p className="text-secondary mb-8">Secure management for distributed operations.</p>
|
<p className="text-secondary mb-8">Cultivation Management Platform</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link to="/login" className="btn btn-primary">
|
<Link to="/login" className="btn btn-primary">
|
||||||
Login
|
Login
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Shield, ArrowRight, Loader2, Key, ChevronRight, Lock } from 'lucide-react';
|
import { Shield, ArrowRight, Loader2, Key, ChevronRight, Lock } from 'lucide-react';
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { DevTools } from '../components/dev/DevTools';
|
|
||||||
import { pageVariants, itemVariants } from '../lib/animations';
|
import { pageVariants, itemVariants } from '../lib/animations';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|
@ -27,7 +27,7 @@ export default function LoginPage() {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/login', { email, password });
|
const { data } = await api.post('/auth/login', { email, password });
|
||||||
login(data.accessToken, data.refreshToken, data.user);
|
login(data.accessToken, data.refreshToken, data.user);
|
||||||
navigate('/dashboard');
|
navigate('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || 'Authentication failed. Please check your credentials.');
|
setError(err.response?.data?.message || 'Authentication failed. Please check your credentials.');
|
||||||
} finally {
|
} 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"
|
className="lg:hidden relative group w-fit mb-8 select-none active:scale-95 transition-transform"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const now = Date.now();
|
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
|
// Reset if too much time passed
|
||||||
if ((window as any).lastTap && now - (window as any).lastTap > 1000) {
|
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).lastTap = now;
|
||||||
(window as any).tapCount = ((window as any).tapCount || 0) + 1;
|
(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;
|
(window as any).tapCount = 0;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -119,8 +119,8 @@ export default function LoginPage() {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
email: 'tenwest@proton.me',
|
email: 'admin@runfoo.run',
|
||||||
password: '2GreenSlugs!'
|
password: 'password123'
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -130,7 +130,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
login(data.accessToken, data.refreshToken, data.user);
|
login(data.accessToken, data.refreshToken, data.user);
|
||||||
navigate('/dashboard');
|
navigate('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Auto-login error:', err);
|
console.error('Auto-login error:', err);
|
||||||
setError(`Login failed: ${err.message}`);
|
setError(`Login failed: ${err.message}`);
|
||||||
|
|
@ -242,7 +242,7 @@ export default function LoginPage() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DevTools />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useState, useEffect } from 'react';
|
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 { analyticsApi, YieldAnalytics, TaskAnalytics } from '../lib/analyticsApi';
|
||||||
import { PageHeader, EmptyState, MetricCard, CardSkeleton } from '../components/ui/LinearPrimitives';
|
import { PageHeader, EmptyState, MetricCard, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||||
|
import { CertificateDownloadButton, LabelDownloadButton } from '../components/ui/PDFDownloadButton';
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [yieldData, setYieldData] = useState<YieldAnalytics | null>(null);
|
const [yieldData, setYieldData] = useState<YieldAnalytics | null>(null);
|
||||||
const [taskData, setTaskData] = useState<TaskAnalytics | 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);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -53,6 +54,20 @@ export default function ReportsPage() {
|
||||||
<BarChart3 size={16} />
|
<BarChart3 size={16} />
|
||||||
Task Analytics
|
Task Analytics
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -217,6 +232,134 @@ export default function ReportsPage() {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,10 @@ export default function RoomsPage() {
|
||||||
Generate Zone
|
Generate Zone
|
||||||
</button>
|
</button>
|
||||||
{isManager && (
|
{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} />
|
<Plus size={18} />
|
||||||
Add Zone
|
Add Zone
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -100,9 +100,9 @@ export default function TasksPage() {
|
||||||
{/* Metrics Row */}
|
{/* Metrics Row */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<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="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="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').length} icon={Clock} color="text-[var(--color-warning)]" />
|
<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={completedTasks.length} icon={CheckCircle2} color="text-[var(--color-accent)]" />
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||||
|
|
|
||||||