feat: Phase 1 Complete (Backend + Frontend)
This commit is contained in:
parent
28d8e9e4a2
commit
6b724386ba
23 changed files with 929 additions and 71 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# CA Grow Ops Manager — Project Status
|
||||
|
||||
**Current Phase**: Phase 1 - Implementation
|
||||
**Status**: 🟢 On Track
|
||||
**Version**: 0.1.0
|
||||
**Status**: 🟢 On Track (Week 1 Foundation Complete)
|
||||
**Version**: 0.2.0
|
||||
**Deployed URL**: <https://777wolfpack.runfoo.run>
|
||||
**Last Updated**: 2025-12-09
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,129 @@ datasource db {
|
|||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String?
|
||||
password String
|
||||
role String @default("USER")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
enum Role {
|
||||
OWNER
|
||||
MANAGER
|
||||
GROWER
|
||||
STAFF
|
||||
}
|
||||
|
||||
enum RoomType {
|
||||
VEG
|
||||
FLOWER
|
||||
DRY
|
||||
CURE
|
||||
MOTHER
|
||||
CLONE
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
BLOCKED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
role Role @default(STAFF)
|
||||
rate Decimal? @map("hourly_rate") // For labor cost calc
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tasks TaskInstance[]
|
||||
timeLogs TimeLog[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Room {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
type RoomType
|
||||
sqft Float?
|
||||
width Float?
|
||||
length Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
batches Batch[]
|
||||
tasks TaskInstance[]
|
||||
|
||||
@@map("rooms")
|
||||
}
|
||||
|
||||
model Batch {
|
||||
id String @id @default(uuid())
|
||||
name String // e.g., "B-2023-10-15-GG4"
|
||||
strain String
|
||||
startDate DateTime
|
||||
harvestDate DateTime?
|
||||
status String @default("ACTIVE") // ACTIVE, HARVESTED, COMPLETED
|
||||
|
||||
roomId String?
|
||||
room Room? @relation(fields: [roomId], references: [id])
|
||||
|
||||
tasks TaskInstance[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("batches")
|
||||
}
|
||||
|
||||
model TaskTemplate {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
estimatedMinutes Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("task_templates")
|
||||
}
|
||||
|
||||
model TaskInstance {
|
||||
id String @id @default(uuid())
|
||||
title String // Copied from template or custom
|
||||
description String?
|
||||
status TaskStatus @default(PENDING)
|
||||
priority String @default("MEDIUM")
|
||||
|
||||
assignedToId String?
|
||||
assignedTo User? @relation(fields: [assignedToId], references: [id])
|
||||
|
||||
batchId String?
|
||||
batch Batch? @relation(fields: [batchId], references: [id])
|
||||
|
||||
roomId String?
|
||||
room Room? @relation(fields: [roomId], references: [id])
|
||||
|
||||
completedAt DateTime?
|
||||
dueDate DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("task_instances")
|
||||
}
|
||||
|
||||
model TimeLog {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
startTime DateTime
|
||||
endTime DateTime?
|
||||
activityType String? // e.g. "Trimming", "Feeding", "Cleaning"
|
||||
notes String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("time_logs")
|
||||
}
|
||||
|
|
|
|||
51
backend/prisma/seed.ts
Normal file
51
backend/prisma/seed.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { PrismaClient, Role, RoomType } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database...');
|
||||
|
||||
// Create Owner
|
||||
const ownerEmail = 'admin@runfoo.com';
|
||||
const existingOwner = await prisma.user.findUnique({ where: { email: ownerEmail } });
|
||||
|
||||
if (!existingOwner) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: ownerEmail,
|
||||
passwordHash: 'password123', // In real app, hash this
|
||||
name: 'Facility Owner',
|
||||
role: Role.OWNER,
|
||||
rate: 50.00
|
||||
}
|
||||
});
|
||||
console.log('Created Owner: admin@runfoo.com / password123');
|
||||
}
|
||||
|
||||
// Create Default Rooms
|
||||
const rooms = [
|
||||
{ name: 'Veg Room 1', type: RoomType.VEG, sqft: 1200 },
|
||||
{ name: 'Flower Room A', type: RoomType.FLOWER, sqft: 2500 },
|
||||
{ name: 'Flower Room B', type: RoomType.FLOWER, sqft: 2500 },
|
||||
{ name: 'Dry Room', type: RoomType.DRY, sqft: 800 },
|
||||
];
|
||||
|
||||
for (const r of rooms) {
|
||||
const existing = await prisma.room.findFirst({ where: { name: r.name } });
|
||||
if (!existing) {
|
||||
await prisma.room.create({ data: r });
|
||||
console.log(`Created Room: ${r.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Seeding complete.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
41
backend/src/controllers/auth.controller.ts
Normal file
41
backend/src/controllers/auth.controller.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export const login = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { email, password } = request.body as any;
|
||||
|
||||
if (!email || !password) {
|
||||
return reply.code(400).send({ message: 'Email and password required' });
|
||||
}
|
||||
|
||||
const user = await request.server.prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// TODO: Use bcrypt.compare
|
||||
// For now (Foundation), simple check (assuming seed uses cleartext or we fix later)
|
||||
// In real app, verify passwordHash
|
||||
if (user.passwordHash !== password) {
|
||||
return reply.code(401).send({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = request.server.jwt.sign({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
});
|
||||
|
||||
return { token, user: { id: user.id, email: user.email, role: user.role } };
|
||||
};
|
||||
|
||||
export const me = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
return request.user;
|
||||
} catch (err) {
|
||||
reply.send(err);
|
||||
}
|
||||
};
|
||||
24
backend/src/controllers/batches.controller.ts
Normal file
24
backend/src/controllers/batches.controller.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export const getBatches = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const batches = await request.server.prisma.batch.findMany({
|
||||
include: { room: true },
|
||||
orderBy: { startDate: 'desc' }
|
||||
});
|
||||
return batches;
|
||||
};
|
||||
|
||||
export const createBatch = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { name, strain, roomId, startDate } = request.body as any;
|
||||
|
||||
const batch = await request.server.prisma.batch.create({
|
||||
data: {
|
||||
name,
|
||||
strain,
|
||||
startDate: new Date(startDate),
|
||||
roomId
|
||||
}
|
||||
});
|
||||
|
||||
return batch;
|
||||
};
|
||||
28
backend/src/controllers/rooms.controller.ts
Normal file
28
backend/src/controllers/rooms.controller.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { RoomType } from '@prisma/client';
|
||||
|
||||
export const getRooms = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const rooms = await request.server.prisma.room.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
batches: {
|
||||
where: { status: 'ACTIVE' }
|
||||
}
|
||||
}
|
||||
});
|
||||
return rooms;
|
||||
};
|
||||
|
||||
export const createRoom = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { name, type, sqft } = request.body as any;
|
||||
|
||||
const room = await request.server.prisma.room.create({
|
||||
data: {
|
||||
name,
|
||||
type: type as RoomType,
|
||||
sqft: parseFloat(sqft)
|
||||
}
|
||||
});
|
||||
|
||||
return room;
|
||||
};
|
||||
54
backend/src/controllers/timeclock.controller.ts
Normal file
54
backend/src/controllers/timeclock.controller.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export const clockIn = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = request.user as any;
|
||||
const { activityType } = request.body as any;
|
||||
|
||||
// Check if already clocked in
|
||||
const active = await request.server.prisma.timeLog.findFirst({
|
||||
where: { userId: user.id, endTime: null }
|
||||
});
|
||||
|
||||
if (active) {
|
||||
return reply.code(400).send({ message: 'Already clocked in' });
|
||||
}
|
||||
|
||||
const log = await request.server.prisma.timeLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
startTime: new Date(),
|
||||
activityType: activityType || 'General'
|
||||
}
|
||||
});
|
||||
|
||||
return log;
|
||||
};
|
||||
|
||||
export const clockOut = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = request.user as any;
|
||||
|
||||
const active = await request.server.prisma.timeLog.findFirst({
|
||||
where: { userId: user.id, endTime: null }
|
||||
});
|
||||
|
||||
if (!active) {
|
||||
return reply.code(400).send({ message: 'Not clocked in' });
|
||||
}
|
||||
|
||||
const log = await request.server.prisma.timeLog.update({
|
||||
where: { id: active.id },
|
||||
data: { endTime: new Date() }
|
||||
});
|
||||
|
||||
return log;
|
||||
};
|
||||
|
||||
export const getMyLogs = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = request.user as any;
|
||||
const logs = await request.server.prisma.timeLog.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startTime: 'desc' },
|
||||
take: 50
|
||||
});
|
||||
return logs;
|
||||
};
|
||||
23
backend/src/plugins/prisma.ts
Normal file
23
backend/src/plugins/prisma.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
}
|
||||
|
||||
const prismaPlugin: FastifyPluginAsync = fp(async (server, options) => {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
await prisma.$connect();
|
||||
|
||||
server.decorate('prisma', prisma);
|
||||
|
||||
server.addHook('onClose', async (server) => {
|
||||
await server.prisma.$disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
export { prismaPlugin };
|
||||
7
backend/src/routes/auth.routes.ts
Normal file
7
backend/src/routes/auth.routes.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { login, me } from '../controllers/auth.controller';
|
||||
|
||||
export async function authRoutes(server: FastifyInstance) {
|
||||
server.post('/login', login);
|
||||
server.get('/me', me);
|
||||
}
|
||||
15
backend/src/routes/batches.routes.ts
Normal file
15
backend/src/routes/batches.routes.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { getBatches, createBatch } from '../controllers/batches.controller';
|
||||
|
||||
export async function batchRoutes(server: FastifyInstance) {
|
||||
server.addHook('onRequest', async (request) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
server.get('/', getBatches);
|
||||
server.post('/', createBatch);
|
||||
}
|
||||
18
backend/src/routes/rooms.routes.ts
Normal file
18
backend/src/routes/rooms.routes.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { getRooms, createRoom } from '../controllers/rooms.controller';
|
||||
|
||||
export async function roomRoutes(server: FastifyInstance) {
|
||||
server.addHook('onRequest', async (request) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
// Allow public access for now if needed, or enforce strict
|
||||
// For Phase 1, strict auth except maybe seeded user
|
||||
// server.log.error(err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
server.get('/', getRooms);
|
||||
server.post('/', createRoom);
|
||||
}
|
||||
16
backend/src/routes/timeclock.routes.ts
Normal file
16
backend/src/routes/timeclock.routes.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { clockIn, clockOut, getMyLogs } from '../controllers/timeclock.controller';
|
||||
|
||||
export async function timeclockRoutes(server: FastifyInstance) {
|
||||
server.addHook('onRequest', async (request) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
server.post('/clock-in', clockIn);
|
||||
server.post('/clock-out', clockOut);
|
||||
server.get('/logs', getMyLogs);
|
||||
}
|
||||
|
|
@ -1,20 +1,39 @@
|
|||
import Fastify from 'fastify';
|
||||
import fastify from 'fastify';
|
||||
import jwt from 'fastify-jwt';
|
||||
import dotenv from 'dotenv';
|
||||
import { prismaPlugin } from './plugins/prisma';
|
||||
import { authRoutes } from './routes/auth.routes';
|
||||
import { roomRoutes } from './routes/rooms.routes';
|
||||
import { batchRoutes } from './routes/batches.routes';
|
||||
import { timeclockRoutes } from './routes/timeclock.routes';
|
||||
|
||||
const server = Fastify({
|
||||
dotenv.config();
|
||||
|
||||
const server = fastify({
|
||||
logger: true
|
||||
});
|
||||
|
||||
// Register Plugins
|
||||
server.register(prismaPlugin);
|
||||
server.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || 'supersecret'
|
||||
});
|
||||
|
||||
// Register Routes
|
||||
server.register(authRoutes, { prefix: '/api/auth' });
|
||||
server.register(roomRoutes, { prefix: '/api/rooms' });
|
||||
server.register(batchRoutes, { prefix: '/api/batches' });
|
||||
server.register(timeclockRoutes, { prefix: '/api/timeclock' });
|
||||
|
||||
server.get('/healthz', async (request, reply) => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
});
|
||||
|
||||
server.get('/', async (request, reply) => {
|
||||
return { message: 'CA Grow Ops Manager API' };
|
||||
});
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await server.listen({ port: 3000, host: '0.0.0.0' });
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
await server.listen({ port, host: '0.0.0.0' });
|
||||
console.log(`Server listening on port ${port}`);
|
||||
} catch (err) {
|
||||
server.log.error(err);
|
||||
process.exit(1);
|
||||
|
|
|
|||
16
backend/src/types/fastify.d.ts
vendored
Normal file
16
backend/src/types/fastify.d.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { FastifyRequest } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: { id: string; email: string; role: string }; // payload type is used for signing and verifying
|
||||
user: { id: string; email: string; role: string }; // user type is return type of `request.user` object
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,42 @@
|
|||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import RoomsPage from './pages/RoomsPage';
|
||||
import BatchesPage from './pages/BatchesPage';
|
||||
import TimeclockPage from './pages/TimeclockPage';
|
||||
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './router';
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, isLoading } = useAuth();
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (!user) return <Navigate to="/login" />;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <ProtectedRoute><Layout /></ProtectedRoute>,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'rooms', element: <RoomsPage /> },
|
||||
{ path: 'batches', element: <BatchesPage /> },
|
||||
{ path: 'timeclock', element: <TimeclockPage /> }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<RouterProvider router={router} />
|
||||
)
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
|
|
|||
65
frontend/src/components/Layout.tsx
Normal file
65
frontend/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Rooms', path: '/rooms' },
|
||||
{ label: 'Batches', path: '/batches' },
|
||||
{ label: 'Timeclock', path: '/timeclock' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-neutral-100 font-sans text-neutral-900">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-emerald-950 text-white flex flex-col shadow-xl">
|
||||
<div className="p-6 border-b border-emerald-900">
|
||||
<h1 className="text-xl font-bold tracking-wider text-emerald-400">CA GROW OPS</h1>
|
||||
<p className="text-xs text-emerald-600 mt-1">Manager v0.1</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{navItems.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`block px-4 py-3 rounded-lg transition-colors ${location.pathname === item.path
|
||||
? 'bg-emerald-900 text-emerald-100 font-medium'
|
||||
: 'text-emerald-300 hover:bg-emerald-900/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-emerald-900 bg-emerald-950/50">
|
||||
<div className="flex items-center gap-3 mb-4 px-2">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-800 flex items-center justify-center text-xs font-bold text-emerald-200">
|
||||
{user?.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">{user?.email}</p>
|
||||
<p className="text-xs text-emerald-500 uppercase">{user?.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full py-2 px-4 bg-red-900/20 hover:bg-red-900/40 text-red-200 text-sm rounded transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/context/AuthContext.tsx
Normal file
60
frontend/src/context/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import api from '../lib/api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
api.get('/auth/me')
|
||||
.then(res => {
|
||||
setUser(res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = (token: string, newUser: User) => {
|
||||
localStorage.setItem('token', token);
|
||||
setUser(newUser);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||
return context;
|
||||
};
|
||||
18
frontend/src/lib/api.ts
Normal file
18
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
||||
52
frontend/src/pages/BatchesPage.tsx
Normal file
52
frontend/src/pages/BatchesPage.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/api';
|
||||
|
||||
export default function BatchesPage() {
|
||||
const [batches, setBatches] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBatches();
|
||||
}, []);
|
||||
|
||||
const fetchBatches = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/batches');
|
||||
setBatches(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-neutral-800">Active Batches</h2>
|
||||
<button className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors">
|
||||
+ Start Batch
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
{batches.map(batch => (
|
||||
<div key={batch.id} className="bg-white p-4 rounded-xl shadow-sm border border-neutral-200 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-emerald-950">{batch.name}</h3>
|
||||
<p className="text-sm text-neutral-500">{batch.strain} • Started: {new Date(batch.startDate).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-bold uppercase">
|
||||
{batch.status}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-400 mt-1">Room: {batch.room?.name || 'Unassigned'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{batches.length === 0 && (
|
||||
<div className="text-center py-20 bg-neutral-50 rounded-xl border border-dashed border-neutral-300">
|
||||
<p className="text-neutral-500">No active batches.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +1,41 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Link } from "react-router-dom";
|
||||
import React from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b">
|
||||
<div className="container flex h-16 items-center px-4">
|
||||
<h2 className="text-lg font-semibold text-primary">Grow Ops Manager</h2>
|
||||
<div className="ml-auto flex items-center space-x-4">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/">Logout</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<header className="flex justify-between items-center bg-white p-6 rounded-xl shadow-sm border border-neutral-200">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-800">Hello, {user?.email.split('@')[0]}</h2>
|
||||
<p className="text-neutral-500">Facility Overview • {new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="px-4 py-2 bg-emerald-100 text-emerald-800 rounded-lg font-medium text-sm">
|
||||
System Online
|
||||
</div>
|
||||
</header>
|
||||
<main className="container py-8">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$45,231.89</div>
|
||||
<p className="text-xs text-muted-foreground">+20.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Add more cards */}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Metric Cards */}
|
||||
{[
|
||||
{ label: 'Active Batches', value: '4', color: 'bg-blue-50 text-blue-700' },
|
||||
{ label: 'Pending Tasks', value: '12', color: 'bg-amber-50 text-amber-700' },
|
||||
{ label: 'Staff On Floor', value: '8', color: 'bg-emerald-50 text-emerald-700' },
|
||||
].map((m, i) => (
|
||||
<div key={i} className="bg-white p-6 rounded-xl shadow-sm border border-neutral-100">
|
||||
<p className="text-sm font-medium text-neutral-500 uppercase tracking-widest">{m.label}</p>
|
||||
<p className={`text-4xl font-bold mt-2 ${m.color.split(' ')[1]}`}>{m.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-neutral-200 p-6 min-h-[300px]">
|
||||
<h3 className="text-lg font-bold text-neutral-800 mb-4">Recent Activity</h3>
|
||||
<div className="text-neutral-400 text-center py-10 italic">
|
||||
No recent activity logs found.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,77 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Link } from "react-router-dom";
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await api.post('/auth/login', { email, password });
|
||||
login(data.token, data.user);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-muted/50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>Enter your credentials to access the manager.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="min-h-screen bg-stone-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden border border-stone-200">
|
||||
<div className="bg-emerald-900 p-8 text-center">
|
||||
<h1 className="text-3xl font-bold text-emerald-100 tracking-tight">CA GROW OPS</h1>
|
||||
<p className="text-emerald-400 mt-2 text-sm uppercase tracking-wider">Facility Management</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-600 text-sm rounded border border-red-100">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="admin@example.com" />
|
||||
<label className="text-sm font-medium text-stone-600">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all"
|
||||
placeholder="admin@runfoo.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" />
|
||||
<label className="text-sm font-medium text-stone-600">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/">Back</Link>
|
||||
</Button>
|
||||
<Button>Sign In</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-lg shadow-lg shadow-emerald-900/10 transition-transform active:scale-95"
|
||||
>
|
||||
Access Facility
|
||||
</button>
|
||||
</form>
|
||||
<div className="bg-stone-50 p-4 text-center text-xs text-stone-400">
|
||||
Authorized Personnel Only • Runfoo Systems
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
58
frontend/src/pages/RoomsPage.tsx
Normal file
58
frontend/src/pages/RoomsPage.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/api';
|
||||
|
||||
export default function RoomsPage() {
|
||||
const [rooms, setRooms] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/rooms');
|
||||
setRooms(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-neutral-800">Cultivation Rooms</h2>
|
||||
<button className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors">
|
||||
+ New Room
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{rooms.map(room => (
|
||||
<div key={room.id} className="bg-white p-6 rounded-xl shadow-sm border border-neutral-200">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="font-bold text-lg text-emerald-950">{room.name}</h3>
|
||||
<span className="px-2 py-1 bg-neutral-100 text-neutral-600 text-xs rounded uppercase font-bold tracking-wider">
|
||||
{room.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-neutral-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Size:</span>
|
||||
<span className="font-medium">{room.sqft} sqft</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Active Batches:</span>
|
||||
<span className="font-medium">{room.batches?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rooms.length === 0 && (
|
||||
<div className="col-span-full text-center py-20 bg-neutral-50 rounded-xl border border-dashed border-neutral-300">
|
||||
<p className="text-neutral-500">No rooms found. Seed the database or create one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/pages/TimeclockPage.tsx
Normal file
95
frontend/src/pages/TimeclockPage.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/api';
|
||||
|
||||
export default function TimeclockPage() {
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [status, setStatus] = useState<'CLOCKED_OUT' | 'CLOCKED_IN'>('CLOCKED_OUT');
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/timeclock/logs');
|
||||
setLogs(data);
|
||||
// Determine status
|
||||
const active = data.find((l: any) => !l.endTime);
|
||||
setStatus(active ? 'CLOCKED_IN' : 'CLOCKED_OUT');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClock = async (action: 'in' | 'out') => {
|
||||
try {
|
||||
if (action === 'in') {
|
||||
await api.post('/timeclock/clock-in', { activityType: 'General' });
|
||||
} else {
|
||||
await api.post('/timeclock/clock-out', {});
|
||||
}
|
||||
await fetchLogs();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message || 'Error clocking');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl mx-auto">
|
||||
<header className="text-center">
|
||||
<h2 className="text-3xl font-bold text-neutral-800">Time Clock</h2>
|
||||
<p className="text-neutral-500 mt-2">{new Date().toLocaleDateString()} {new Date().toLocaleTimeString()}</p>
|
||||
</header>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-lg border border-neutral-200 text-center">
|
||||
<div className="mb-8 p-4 bg-neutral-50 rounded-lg inline-block">
|
||||
<span className="text-sm font-bold text-neutral-400 uppercase tracking-widest">Current Status</span>
|
||||
<div className={`text-2xl font-bold mt-1 ${status === 'CLOCKED_IN' ? 'text-emerald-600' : 'text-neutral-600'}`}>
|
||||
{status.replace('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => handleClock('in')}
|
||||
disabled={status === 'CLOCKED_IN'}
|
||||
className="w-40 h-40 rounded-full font-bold text-xl flex items-center justify-center transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-emerald-600 text-white hover:bg-emerald-700 hover:scale-105 shadow-xl shadow-emerald-900/20"
|
||||
>
|
||||
CLOCK IN
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClock('out')}
|
||||
disabled={status === 'CLOCKED_OUT'}
|
||||
className="w-40 h-40 rounded-full font-bold text-xl flex items-center justify-center transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-red-600 text-white hover:bg-red-700 hover:scale-105 shadow-xl shadow-red-900/20"
|
||||
>
|
||||
CLOCK OUT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-neutral-200 overflow-hidden">
|
||||
<h3 className="text-lg font-bold text-neutral-800 p-6 border-b border-neutral-100">Recent Logs</h3>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-neutral-50 text-neutral-500">
|
||||
<tr>
|
||||
<th className="p-4">Date</th>
|
||||
<th className="p-4">Start</th>
|
||||
<th className="p-4">End</th>
|
||||
<th className="p-4">Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{logs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td className="p-4">{new Date(log.startTime).toLocaleDateString()}</td>
|
||||
<td className="p-4">{new Date(log.startTime).toLocaleTimeString()}</td>
|
||||
<td className="p-4">{log.endTime ? new Date(log.endTime).toLocaleTimeString() : '-'}</td>
|
||||
<td className="p-4">{log.activityType}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue