From 6b724386ba95b966eed5dc93dccc116ab65335dc Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:24:00 -0800 Subject: [PATCH] feat: Phase 1 Complete (Backend + Frontend) --- STATUS.md | 4 +- backend/prisma/schema.prisma | 133 ++++++++++++++++-- backend/prisma/seed.ts | 51 +++++++ backend/src/controllers/auth.controller.ts | 41 ++++++ backend/src/controllers/batches.controller.ts | 24 ++++ backend/src/controllers/rooms.controller.ts | 28 ++++ .../src/controllers/timeclock.controller.ts | 54 +++++++ backend/src/plugins/prisma.ts | 23 +++ backend/src/routes/auth.routes.ts | 7 + backend/src/routes/batches.routes.ts | 15 ++ backend/src/routes/rooms.routes.ts | 18 +++ backend/src/routes/timeclock.routes.ts | 16 +++ backend/src/server.ts | 33 ++++- backend/src/types/fastify.d.ts | 16 +++ frontend/src/App.tsx | 41 +++++- frontend/src/components/Layout.tsx | 65 +++++++++ frontend/src/context/AuthContext.tsx | 60 ++++++++ frontend/src/lib/api.ts | 18 +++ frontend/src/pages/BatchesPage.tsx | 52 +++++++ frontend/src/pages/DashboardPage.tsx | 57 ++++---- frontend/src/pages/LoginPage.tsx | 91 ++++++++---- frontend/src/pages/RoomsPage.tsx | 58 ++++++++ frontend/src/pages/TimeclockPage.tsx | 95 +++++++++++++ 23 files changed, 929 insertions(+), 71 deletions(-) create mode 100644 backend/prisma/seed.ts create mode 100644 backend/src/controllers/auth.controller.ts create mode 100644 backend/src/controllers/batches.controller.ts create mode 100644 backend/src/controllers/rooms.controller.ts create mode 100644 backend/src/controllers/timeclock.controller.ts create mode 100644 backend/src/plugins/prisma.ts create mode 100644 backend/src/routes/auth.routes.ts create mode 100644 backend/src/routes/batches.routes.ts create mode 100644 backend/src/routes/rooms.routes.ts create mode 100644 backend/src/routes/timeclock.routes.ts create mode 100644 backend/src/types/fastify.d.ts create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/pages/BatchesPage.tsx create mode 100644 frontend/src/pages/RoomsPage.tsx create mode 100644 frontend/src/pages/TimeclockPage.tsx diff --git a/STATUS.md b/STATUS.md index 7832989..6f69cb5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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**: **Last Updated**: 2025-12-09 diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 066ca29..ee53b42 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") } diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..88be2d1 --- /dev/null +++ b/backend/prisma/seed.ts @@ -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(); + }); diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts new file mode 100644 index 0000000..7a12979 --- /dev/null +++ b/backend/src/controllers/auth.controller.ts @@ -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); + } +}; diff --git a/backend/src/controllers/batches.controller.ts b/backend/src/controllers/batches.controller.ts new file mode 100644 index 0000000..e2a9384 --- /dev/null +++ b/backend/src/controllers/batches.controller.ts @@ -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; +}; diff --git a/backend/src/controllers/rooms.controller.ts b/backend/src/controllers/rooms.controller.ts new file mode 100644 index 0000000..7b50a26 --- /dev/null +++ b/backend/src/controllers/rooms.controller.ts @@ -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; +}; diff --git a/backend/src/controllers/timeclock.controller.ts b/backend/src/controllers/timeclock.controller.ts new file mode 100644 index 0000000..012f491 --- /dev/null +++ b/backend/src/controllers/timeclock.controller.ts @@ -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; +}; diff --git a/backend/src/plugins/prisma.ts b/backend/src/plugins/prisma.ts new file mode 100644 index 0000000..b376ec2 --- /dev/null +++ b/backend/src/plugins/prisma.ts @@ -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 }; diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..cad0c6d --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -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); +} diff --git a/backend/src/routes/batches.routes.ts b/backend/src/routes/batches.routes.ts new file mode 100644 index 0000000..d2167bb --- /dev/null +++ b/backend/src/routes/batches.routes.ts @@ -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); +} diff --git a/backend/src/routes/rooms.routes.ts b/backend/src/routes/rooms.routes.ts new file mode 100644 index 0000000..5afe069 --- /dev/null +++ b/backend/src/routes/rooms.routes.ts @@ -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); +} diff --git a/backend/src/routes/timeclock.routes.ts b/backend/src/routes/timeclock.routes.ts new file mode 100644 index 0000000..8db4a3d --- /dev/null +++ b/backend/src/routes/timeclock.routes.ts @@ -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); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index debd4db..b0d58fc 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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); diff --git a/backend/src/types/fastify.d.ts b/backend/src/types/fastify.d.ts new file mode 100644 index 0000000..c013475 --- /dev/null +++ b/backend/src/types/fastify.d.ts @@ -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; + } +} + +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 + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbc9f9c..a9cbc8a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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
Loading...
; + if (!user) return ; + return <>{children}; +}; + +const router = createBrowserRouter([ + { + path: '/login', + element: + }, + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'rooms', element: }, + { path: 'batches', element: }, + { path: 'timeclock', element: } + ] + } +]); function App() { return ( - - ) + + + + ); } -export default App +export default App; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..2fe587d --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ +
+
+ ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..8141c01 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -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(null); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [user, setUser] = useState(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 ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used within AuthProvider'); + return context; +}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..d4bf41d --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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; diff --git a/frontend/src/pages/BatchesPage.tsx b/frontend/src/pages/BatchesPage.tsx new file mode 100644 index 0000000..c293971 --- /dev/null +++ b/frontend/src/pages/BatchesPage.tsx @@ -0,0 +1,52 @@ +import React, { useEffect, useState } from 'react'; +import api from '../lib/api'; + +export default function BatchesPage() { + const [batches, setBatches] = useState([]); + + useEffect(() => { + fetchBatches(); + }, []); + + const fetchBatches = async () => { + try { + const { data } = await api.get('/batches'); + setBatches(data); + } catch (e) { + console.error(e); + } + }; + + return ( +
+
+

Active Batches

+ +
+ +
+ {batches.map(batch => ( +
+
+

{batch.name}

+

{batch.strain} • Started: {new Date(batch.startDate).toLocaleDateString()}

+
+
+ + {batch.status} + +

Room: {batch.room?.name || 'Unassigned'}

+
+
+ ))} + {batches.length === 0 && ( +
+

No active batches.

+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index deaa30c..3609e39 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 ( -
-
-
-

Grow Ops Manager

-
- -
+
+
+
+

Hello, {user?.email.split('@')[0]}

+

Facility Overview • {new Date().toLocaleDateString()}

+
+
+ System Online
-
-
- - - Total Revenue - - -
$45,231.89
-

+20.1% from last month

-
-
- {/* Add more cards */} + +
+ {/* 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) => ( +
+

{m.label}

+

{m.value}

+
+ ))} +
+ +
+

Recent Activity

+
+ No recent activity logs found.
-
+
); } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 27e05bb..96fc4bc 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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 ( -
- - - Login - Enter your credentials to access the manager. - - +
+
+
+

CA GROW OPS

+

Facility Management

+
+ +
+ {error && ( +
+ {error} +
+ )} +
- - + + setEmail(e.target.value)} + />
+
- - + + setPassword(e.target.value)} + />
- - - - - - + + +
+
+ Authorized Personnel Only • Runfoo Systems +
+
); } diff --git a/frontend/src/pages/RoomsPage.tsx b/frontend/src/pages/RoomsPage.tsx new file mode 100644 index 0000000..ebef3f2 --- /dev/null +++ b/frontend/src/pages/RoomsPage.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from 'react'; +import api from '../lib/api'; + +export default function RoomsPage() { + const [rooms, setRooms] = useState([]); + + useEffect(() => { + fetchRooms(); + }, []); + + const fetchRooms = async () => { + try { + const { data } = await api.get('/rooms'); + setRooms(data); + } catch (e) { + console.error(e); + } + }; + + return ( +
+
+

Cultivation Rooms

+ +
+ +
+ {rooms.map(room => ( +
+
+

{room.name}

+ + {room.type} + +
+
+
+ Size: + {room.sqft} sqft +
+
+ Active Batches: + {room.batches?.length || 0} +
+
+
+ ))} + {rooms.length === 0 && ( +
+

No rooms found. Seed the database or create one.

+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/TimeclockPage.tsx b/frontend/src/pages/TimeclockPage.tsx new file mode 100644 index 0000000..490db1f --- /dev/null +++ b/frontend/src/pages/TimeclockPage.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useState } from 'react'; +import api from '../lib/api'; + +export default function TimeclockPage() { + const [logs, setLogs] = useState([]); + 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 ( +
+
+

Time Clock

+

{new Date().toLocaleDateString()} {new Date().toLocaleTimeString()}

+
+ +
+
+ Current Status +
+ {status.replace('_', ' ')} +
+
+ +
+ + +
+
+ +
+

Recent Logs

+ + + + + + + + + + + {logs.map(log => ( + + + + + + + ))} + +
DateStartEndActivity
{new Date(log.startTime).toLocaleDateString()}{new Date(log.startTime).toLocaleTimeString()}{log.endTime ? new Date(log.endTime).toLocaleTimeString() : '-'}{log.activityType}
+
+
+ ); +}