Compare commits
110 commits
veridian-r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3023155fde | ||
|
|
477c31db65 | ||
|
|
afe00b3c45 | ||
|
|
d294f1746f | ||
|
|
a777feae2b | ||
|
|
06addc52f0 | ||
|
|
23e1720dd1 | ||
|
|
37731a37da | ||
|
|
998f9b89e7 | ||
|
|
36705cd257 | ||
|
|
813e4ac70c | ||
|
|
6110530943 | ||
|
|
bc78836bf6 | ||
|
|
7159d48b06 | ||
|
|
98d729f87f | ||
|
|
b95302c451 | ||
|
|
34b34bd5b5 | ||
|
|
de632bd425 | ||
|
|
b6fbb5c8b7 | ||
|
|
7567efe51e | ||
|
|
702bf87552 | ||
|
|
fb1e3c05c7 | ||
|
|
f97e8ea1d0 | ||
|
|
ed7b78be32 | ||
|
|
5482676f06 | ||
|
|
c74f37783f | ||
|
|
0723c93908 | ||
|
|
44f1939b2b | ||
|
|
57c70b91db | ||
|
|
469286deac | ||
|
|
ad875443ed | ||
|
|
41dcdce993 | ||
|
|
64d7d56792 | ||
|
|
dc403c29f5 | ||
|
|
2998b90fe0 | ||
|
|
add6c6d305 | ||
|
|
28532d4d9b | ||
|
|
7cb7843ceb | ||
|
|
c39abe5696 | ||
|
|
55bdef78e4 | ||
|
|
14e76f2cdf | ||
|
|
e4c506d074 | ||
|
|
6ae2b35f8d | ||
|
|
22d0668ba1 | ||
|
|
1abb972d37 | ||
|
|
01b6c18f58 | ||
|
|
01da433723 | ||
|
|
fb5dba5019 | ||
|
|
893244169d | ||
|
|
79b6bdbcd2 | ||
|
|
afbd5c69aa | ||
|
|
c3dcefe857 | ||
|
|
215d24eb0e | ||
|
|
af0e6526d6 | ||
|
|
95af9e9f8d | ||
|
|
54531a79d5 | ||
|
|
5c86b98628 | ||
|
|
2ca6fb01f4 | ||
|
|
b520ffc578 | ||
|
|
abaef138ba | ||
|
|
2bc596c527 | ||
|
|
a13d6f6907 | ||
|
|
133bf9ea3a | ||
|
|
6d957f1c92 | ||
|
|
6bdabb0e60 | ||
|
|
dcbb75180d | ||
|
|
2acef3c63c | ||
|
|
c962118ba6 | ||
|
|
7ec8b1fc57 | ||
|
|
ec9e98e696 | ||
|
|
7b5321cb14 | ||
|
|
cbaa341553 | ||
|
|
884e4050ff | ||
|
|
554bf214c1 | ||
|
|
fe5c6decc2 | ||
|
|
f534c9818e | ||
|
|
d9d04045cb | ||
|
|
1a13087c53 | ||
|
|
3cc1830b6c | ||
|
|
0d82bb0db4 | ||
|
|
d4bbc33c64 | ||
|
|
4d04d2f8c7 | ||
|
|
e82dac5ca2 | ||
|
|
8866341a8f | ||
|
|
494ea2f01a | ||
|
|
68b6ea9a02 | ||
|
|
15ab32a75a | ||
|
|
dc2f491fa9 | ||
|
|
dc2cfd13ad | ||
|
|
92c65889ac | ||
|
|
66346cb168 | ||
|
|
feb8b04ba0 | ||
|
|
477c076d03 | ||
|
|
aa8e5d226f | ||
|
|
2f67ad2fe3 | ||
|
|
e88814afef | ||
|
|
1950651102 | ||
|
|
820d345a0c | ||
|
|
22ed334fb3 | ||
|
|
4b37e9fa84 | ||
|
|
f7c71f2e5f | ||
|
|
c9c9eb84f2 | ||
|
|
7a01dc5589 | ||
|
|
1363333746 | ||
|
|
47de301f77 | ||
|
|
15a6b08e0f | ||
|
|
5666970629 | ||
|
|
38f9ef5f0b | ||
|
|
f875664305 | ||
|
|
06abb56560 |
5
.agent/rules/dev.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
---
|
||||||
|
|
||||||
|
workspace is ~/DEV
|
||||||
152
.claude/claude.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# Project Overview
|
||||||
|
|
||||||
|
**Name**: Veridian Cultivation Platform
|
||||||
|
**Type**: Full-stack SaaS application (backend + frontend)
|
||||||
|
**Primary Languages**: TypeScript (Node.js 20, React 18)
|
||||||
|
**Status**: v0.1.0, Internal Testing
|
||||||
|
|
||||||
|
## High-Level Description
|
||||||
|
|
||||||
|
Veridian is a cultivation management platform for licensed cannabis facilities that centralizes:
|
||||||
|
- Grow Operations: Tasks, batches, rooms, and cultivation workflows
|
||||||
|
- Labor Tracking: Timeclock, hours, and cost-per-batch analysis
|
||||||
|
- Compliance: Document storage, audit packets, and METRC alignment
|
||||||
|
- Inventory: Materials, nutrients, and supplies with lot tracking
|
||||||
|
- Integrations: Environmental monitoring and hardware dashboards
|
||||||
|
- Communications: Task comments, announcements, and notifications
|
||||||
|
|
||||||
|
**External Systems**:
|
||||||
|
- PostgreSQL 15.x (primary database via Prisma ORM)
|
||||||
|
- METRC API (read-only in v1, California track-and-trace)
|
||||||
|
- SensorPush environmental sensors (via Veridian Edge agent)
|
||||||
|
- VPS deployment: nexus-vector
|
||||||
|
|
||||||
|
## Multi-Repo Architecture
|
||||||
|
|
||||||
|
This repo works in conjunction with **veridian-edge** as a single codebase:
|
||||||
|
- **veridian** (this repo): Main platform backend + frontend
|
||||||
|
- **veridian-edge**: SensorPush edge agent that polls SensorPush Cloud API and syncs environmental readings to Veridian backend
|
||||||
|
|
||||||
|
When making changes, consider the API contract between these two repos:
|
||||||
|
- Edge agent → Veridian backend API endpoints
|
||||||
|
- Data models must match between edge agent and backend
|
||||||
|
- Authentication/authorization patterns
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
veridian/
|
||||||
|
├── backend/ # Express/Fastify backend API
|
||||||
|
│ ├── src/ # Source code
|
||||||
|
│ ├── prisma/ # Database schema and migrations
|
||||||
|
│ └── package.json # Node.js dependencies
|
||||||
|
├── frontend/ # React + Vite frontend
|
||||||
|
│ ├── src/ # Source code
|
||||||
|
│ └── package.json # Dependencies
|
||||||
|
├── docs/ # Architecture and compliance docs
|
||||||
|
├── specs/ # Feature specifications (Spec Kit workflow)
|
||||||
|
├── design-os/ # Design system components
|
||||||
|
└── docker-compose.yml # Local development stack
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Language/Style
|
||||||
|
- **TypeScript strict mode** enabled
|
||||||
|
- **ESLint + Prettier** for formatting
|
||||||
|
- **Prisma 5.x** for database access
|
||||||
|
- **Conventional commits** for git messages
|
||||||
|
|
||||||
|
### Package Manager
|
||||||
|
- **Use `bun` instead of `npm`** by default
|
||||||
|
- Commands: `bun install`, `bun run`, `bun test`, `bun add`
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Throw semantic errors with clear messages
|
||||||
|
- Use Result types for operations that can fail
|
||||||
|
- Never expose sensitive data in error messages
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
- Use structured logging (JSON in production)
|
||||||
|
- No `console.log` in production code
|
||||||
|
- Log levels: error, warn, info, debug
|
||||||
|
|
||||||
|
### Config
|
||||||
|
- Read from environment variables
|
||||||
|
- Never hard-code secrets or API keys
|
||||||
|
- Use `.env.example` as template
|
||||||
|
|
||||||
|
## Patterns & Playbooks
|
||||||
|
|
||||||
|
### How to Add New API Endpoints
|
||||||
|
1. Update Prisma schema if needed (`prisma/schema.prisma`)
|
||||||
|
2. Run migration: `bunx prisma migrate dev --name description`
|
||||||
|
3. Create route handler in `backend/src/routes/`
|
||||||
|
4. Add request validation (zod or similar)
|
||||||
|
5. Implement business logic in service layer
|
||||||
|
6. Add tests in `backend/src/__tests__/`
|
||||||
|
7. Update API documentation
|
||||||
|
|
||||||
|
### How to Add New Frontend Components
|
||||||
|
1. Create component in `frontend/src/components/`
|
||||||
|
2. Follow existing design system patterns (see `design-os/`)
|
||||||
|
3. Use TypeScript with proper prop types
|
||||||
|
4. Add responsive styles (Tailwind CSS)
|
||||||
|
5. Create tests if component has logic
|
||||||
|
6. Export from component index file
|
||||||
|
|
||||||
|
### How to Write Database Migrations
|
||||||
|
1. Modify `backend/prisma/schema.prisma`
|
||||||
|
2. Run `bunx prisma migrate dev --name description`
|
||||||
|
3. Generate client: `bunx prisma generate`
|
||||||
|
4. For production: `bunx prisma migrate deploy`
|
||||||
|
|
||||||
|
### How to Run Locally
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
bun install
|
||||||
|
cp .env.example .env # Configure DB connection
|
||||||
|
bunx prisma migrate dev
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
bun install
|
||||||
|
cp .env.example .env # Configure API URL
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full Stack (Docker):**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important Environment Variables
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `JWT_SECRET`: Secret for JWT token signing
|
||||||
|
- `METRC_API_KEY`: METRC integration (read-only)
|
||||||
|
- `SENSORPUSH_WEBHOOK_SECRET`: Webhook verification for edge agent
|
||||||
|
|
||||||
|
## PR & Learning Workflow
|
||||||
|
|
||||||
|
- When a PR introduces a new pattern or fixes a subtle issue:
|
||||||
|
1. Summarize the lesson in 1-2 bullets
|
||||||
|
2. Append under "Patterns & Playbooks" above
|
||||||
|
3. Consider updating relevant documentation in `docs/`
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit tests**: Jest for pure functions and business logic
|
||||||
|
- **Integration tests**: API endpoint tests with test database
|
||||||
|
- **E2E tests**: Browser tests for critical flows (login, batch creation)
|
||||||
|
- **VPS verification**: After deployment, verify on nexus-vector
|
||||||
|
|
||||||
|
## Compliance Notes
|
||||||
|
|
||||||
|
- **METRC is system of record** for California track-and-trace
|
||||||
|
- All compliance-relevant changes must be documented
|
||||||
|
- Audit logs must capture: who, what, when, for critical operations
|
||||||
|
- See `docs/compliance-notes-ca.md` for detailed guidance
|
||||||
31
.claude/commands/deploy-vps.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
You are the VPS deployment assistant for Veridian.
|
||||||
|
|
||||||
|
## VPS Information
|
||||||
|
- Host: nexus-vector
|
||||||
|
- User: admin
|
||||||
|
- Project path on VPS: /home/admin/honkingversion (adjust if different)
|
||||||
|
|
||||||
|
## Deployment Process
|
||||||
|
|
||||||
|
1. **Pre-deployment checks**:
|
||||||
|
- `git status` to check for uncommitted changes
|
||||||
|
- `git log -1 --oneline` to show what will be deployed
|
||||||
|
|
||||||
|
2. **Deploy to VPS**:
|
||||||
|
```bash
|
||||||
|
ssh admin@nexus-vector 'cd /home/admin/honkingversion && git pull origin master && docker compose down && docker compose up -d --build 2>&1 | tail -50'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Post-deployment verification**:
|
||||||
|
- Check service status: `ssh admin@nexus-vector 'docker compose ps'`
|
||||||
|
- View logs: `ssh admin@nexus-vector 'docker compose logs --tail 50'`
|
||||||
|
- Health check: `curl https://your-veridian-url.com/health` (if available)
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report:
|
||||||
|
- What was deployed (commit hash, message)
|
||||||
|
- Docker build output summary
|
||||||
|
- Service status (running/not running)
|
||||||
|
- Any errors or warnings in logs
|
||||||
|
- Suggested manual verification steps (URLs to check, flows to test)
|
||||||
24
.claude/commands/migrate.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
You are the database migration assistant for Veridian.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Check current state**:
|
||||||
|
- `cd backend && bunx prisma migrate status`
|
||||||
|
- Show any pending migrations
|
||||||
|
|
||||||
|
2. **Create migration**:
|
||||||
|
- Ask for a description of the schema change
|
||||||
|
- Run: `cd backend && bunx prisma migrate dev --name description`
|
||||||
|
- Generate Prisma client: `bunx prisma generate`
|
||||||
|
|
||||||
|
3. **Verify**:
|
||||||
|
- Show the generated SQL
|
||||||
|
- Check if any data migration is needed
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report:
|
||||||
|
- Migration name and number
|
||||||
|
- SQL changes summary
|
||||||
|
- Whether data migration is needed
|
||||||
|
- Next steps (deploy to production, etc.)
|
||||||
2
.gitignore
vendored
|
|
@ -36,3 +36,5 @@ backend/prisma/dev.db
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.env
|
docker-compose.env
|
||||||
|
_SECRETS_BACKUP
|
||||||
|
/design-os/
|
||||||
|
|
|
||||||
355
QUICK-REFERENCE.md
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
# VERIDIAN HARDWARE TIER REPORT - QUICK REFERENCE
|
||||||
|
|
||||||
|
**Deployment:** 5 Greenhouses + 3 Drying Containers
|
||||||
|
**Date:** January 6, 2026
|
||||||
|
**Report:** [VERIDIAN-HARDWARE-TIERS-REPORT.md](./VERIDIAN-HARDWARE-TIERS-REPORT.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 TL;DR (Too Long; Didn't Read)
|
||||||
|
|
||||||
|
### Three Hardware Tiers for 5 GH + 3 Containers
|
||||||
|
|
||||||
|
| Tier | Total Cost | What You Get | Best For |
|
||||||
|
|------|------------|--------------|----------|
|
||||||
|
| **Baseline** | **$2,200** | SensorPush HT1, Kasa EP10, Pi 4, Zigbee soil sensors (optional) | Initial testing, budget-conscious |
|
||||||
|
| **Premium** | **$10,500** | HT.w sensors, CO2, cameras, Pi 5, Soil Scout | Production, small commercial |
|
||||||
|
| **Enterprise** | **$36,600** | PAR sensors, PTZ cameras, Pi cluster, PLC | Commercial scale, maximum uptime |
|
||||||
|
|
||||||
|
**Real 2026 Pricing Applied** - Actual prices are LOWER than estimated!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 What's Included
|
||||||
|
|
||||||
|
### Common to All Tiers:
|
||||||
|
- **26 Air Sensors** (20 greenhouses + 6 containers)
|
||||||
|
- **10 Smart Plugs** (power control for fans, heaters, dehumidifiers)
|
||||||
|
- **Raspberry Pi Edge Device** (runs HomeAssistant + Veridian Edge Agent)
|
||||||
|
- **Network Infrastructure** (WiFi, ethernet, switches)
|
||||||
|
- **SQLite Offline Buffer** (data survives internet outages)
|
||||||
|
|
||||||
|
### Tier-Specific Additions:
|
||||||
|
|
||||||
|
**Baseline ($2,200):**
|
||||||
|
- SensorPush HT1 sensors (±0.36°F accuracy)
|
||||||
|
- Raspberry Pi 4 (4GB RAM)
|
||||||
|
- Kasa EP10 smart plugs (no energy monitoring)
|
||||||
|
- Optional: Zigbee soil sensors ($198) or DIY capacitive ($255)
|
||||||
|
- 30-min UPS battery backup
|
||||||
|
|
||||||
|
**Premium ($10,500):**
|
||||||
|
- SensorPush HT.w sensors (±0.18°F accuracy - 2x better!)
|
||||||
|
- Raspberry Pi 5 (8GB RAM) + NVMe SSD
|
||||||
|
- Kasa EP25 smart plugs (with energy monitoring)
|
||||||
|
- CO2 monitoring (8 zones)
|
||||||
|
- 4MP cameras (8 cameras, 30-day retention)
|
||||||
|
- Soil Scout LoRa sensors (professional-grade)
|
||||||
|
- E-ink displays (8 zones)
|
||||||
|
- 2-hour UPS + 4G LTE failover
|
||||||
|
|
||||||
|
**Enterprise ($36,600):**
|
||||||
|
- Everything in Premium, plus:
|
||||||
|
- PAR light sensors (Apogee SQ-500)
|
||||||
|
- 4K PTZ cameras (90-day retention)
|
||||||
|
- Pi 5 cluster (3 nodes, high availability)
|
||||||
|
- PLC control (industrial automation)
|
||||||
|
- Multi-depth soil sensors (moisture + EC + pH)
|
||||||
|
- Voice annunciation system
|
||||||
|
- 4-6 hour UPS + generator hook-up
|
||||||
|
- Dual 4G LTE internet redundancy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Key Hardware Specifications
|
||||||
|
|
||||||
|
### Environmental Sensors
|
||||||
|
|
||||||
|
**Air Sensors:**
|
||||||
|
- **SensorPush HT1** (Baseline): $54.95 each
|
||||||
|
- Temperature: -40°F to 185°F (±0.36°F)
|
||||||
|
- Humidity: 0-100% RH (±2%)
|
||||||
|
- Battery: 12 months
|
||||||
|
- Data: Every 1 minute
|
||||||
|
|
||||||
|
- **SensorPush HT.w** (Premium/Enterprise): $110 each
|
||||||
|
- Temperature: -40°F to 185°F (±0.18°F) - **2x more accurate!**
|
||||||
|
- Humidity: 0-100% RH (±1%)
|
||||||
|
- Battery: 18 months
|
||||||
|
- Data: Every 30 seconds
|
||||||
|
|
||||||
|
**Soil Sensors (Optional):**
|
||||||
|
|
||||||
|
| Option | Price | Accuracy | Battery | Integration |
|
||||||
|
|--------|-------|----------|---------|-------------|
|
||||||
|
| **Zigbee (Tuya TS0601)** | $18 each | ±5% | 6-12 mo (AAA) | ZHA / Zigbee2MQTT |
|
||||||
|
| **DIY Capacitive** | $8 each | ±5% | N/A (wired) | ADC + Python/Bun |
|
||||||
|
| **Soil Scout (LoRa)** | $150-250 each | ±3% | 5-10 years | MQTT via LoRa gateway |
|
||||||
|
| **Soil Scout Pro** | $250 each | ±2% | 10 years | Multi-depth (6", 12", 18") |
|
||||||
|
|
||||||
|
**CO2 Sensors (Premium/Enterprise):**
|
||||||
|
- **SenseAir S8** (Premium): $200 each
|
||||||
|
- Range: 0-2000 ppm
|
||||||
|
- Accuracy: ±30 ppm ±3%
|
||||||
|
- Output: Modbus RTU
|
||||||
|
|
||||||
|
- **SenseAir S8 Pro** (Enterprise): $350 each
|
||||||
|
- Range: 0-10,000 ppm
|
||||||
|
- Accuracy: ±20 ppm ±2%
|
||||||
|
- Pressure compensation: Yes
|
||||||
|
|
||||||
|
**PAR Sensors (Enterprise only):**
|
||||||
|
- **Apogee SQ-500**: $600 each
|
||||||
|
- Spectral range: 389-683 nm (plant-focused)
|
||||||
|
- Output: 0-2.5V
|
||||||
|
- Weatherproof: IP68
|
||||||
|
|
||||||
|
### Smart Plugs
|
||||||
|
|
||||||
|
| Model | Max Load | Energy Monitor | Price | Best For |
|
||||||
|
|-------|----------|----------------|-------|----------|
|
||||||
|
| **Kasa EP10** | 15A / 1800W | No | $7.50 each | Small loads, basic control |
|
||||||
|
| **Kasa EP25** | 15A / 1800W | Yes | $11.25 each | Medium loads, energy tracking |
|
||||||
|
|
||||||
|
**Note:** Actual prices from 4-packs! EP10 4-pack = $29.99, EP25 4-pack = $44.99
|
||||||
|
|
||||||
|
### Edge Device
|
||||||
|
|
||||||
|
| Model | RAM | Performance | Storage | Price |
|
||||||
|
|-------|-----|-------------|---------|-------|
|
||||||
|
| **Raspberry Pi 4** | 4GB | 1x baseline | 32GB SD | $60 |
|
||||||
|
| **Raspberry Pi 5** | 8GB | 2-3x faster | 500GB NVMe | $95 |
|
||||||
|
|
||||||
|
**Recommendation:** Pi 5 if budget allows (2-3x faster, true Gigabit Ethernet, NVMe support)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Deployment Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Veridian Cloud Backend (VPS)
|
||||||
|
↓ HTTPS/WebSocket
|
||||||
|
Edge Device (Raspberry Pi)
|
||||||
|
├── Home Assistant
|
||||||
|
├── Veridian Edge Agent (Bun)
|
||||||
|
├── SQLite Buffer (offline cache)
|
||||||
|
└── MQTT Broker (optional)
|
||||||
|
↓
|
||||||
|
┌────┴────┬─────────┬────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
SensorPush Kasa Zigbee Cameras/Other
|
||||||
|
Gateway Plugs (soil) (Tier 2/3)
|
||||||
|
│ │ │ │
|
||||||
|
└────┬────┴─────────┴────────────┘
|
||||||
|
↓
|
||||||
|
26 Sensors + 10 Plugs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📍 Sensor Placement
|
||||||
|
|
||||||
|
### Greenhouses (5 total)
|
||||||
|
- **4 air sensors each** (N, S, E, W walls at canopy height)
|
||||||
|
- **2 soil sensors each** (optional - Tier 1: Zigbee/DIY, Tier 2+: Soil Scout)
|
||||||
|
|
||||||
|
### Containers (3 total)
|
||||||
|
- **2 air sensors each** (front and back, eye level)
|
||||||
|
- **No soil sensors** (drying environment)
|
||||||
|
|
||||||
|
**Total Air Sensors:** 26 (5 GH × 4 + 3 Containers × 2)
|
||||||
|
**Total Soil Sensors (optional):** 10 (5 GH × 2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Software Stack
|
||||||
|
|
||||||
|
### Edge Device (Raspberry Pi):
|
||||||
|
- **Home Assistant** (home automation platform)
|
||||||
|
- **Veridian Edge Agent** (Bun runtime, polls SensorPush API)
|
||||||
|
- **SQLite** (offline data buffering)
|
||||||
|
- **MQTT Broker** (optional - for soil sensors, e-ink displays)
|
||||||
|
- **go2rtc** (Tier 2/3 - camera streaming)
|
||||||
|
|
||||||
|
### Integrations:
|
||||||
|
- **SensorPush** (air sensors) - Native HA integration
|
||||||
|
- **TP-Link Kasa** (smart plugs) - Native HA integration
|
||||||
|
- **ZHA** or **Zigbee2MQTT** (soil sensors)
|
||||||
|
- **Modbus** (CO2 sensors - Tier 2/3)
|
||||||
|
- **Veridian Cloud** (backend API, WebSocket)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Example Automations
|
||||||
|
|
||||||
|
### Emergency Ventilation (High Temp)
|
||||||
|
```yaml
|
||||||
|
# If temperature > 85°F for 1 minute → Turn on fan
|
||||||
|
trigger:
|
||||||
|
- platform: numeric_state
|
||||||
|
entity_id: sensor.greenhouse_1_temperature
|
||||||
|
above: 85
|
||||||
|
for:
|
||||||
|
minutes: 1
|
||||||
|
action:
|
||||||
|
- service: switch.turn_on
|
||||||
|
entity_id: switch.greenhouse_1_fan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Low Soil Moisture Alert
|
||||||
|
```yaml
|
||||||
|
# If soil moisture < 30% → Send notification
|
||||||
|
trigger:
|
||||||
|
- platform: numeric_state
|
||||||
|
entity_id: sensor.greenhouse_1_soil_moisture
|
||||||
|
below: 30
|
||||||
|
for:
|
||||||
|
minutes: 30
|
||||||
|
action:
|
||||||
|
- service: notify.mobile_app
|
||||||
|
data:
|
||||||
|
title: "💧 GH1 Low Soil Moisture"
|
||||||
|
message: "Soil moisture at 25%. Watering needed."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Dehumidifier
|
||||||
|
```yaml
|
||||||
|
# If humidity > 65% → Turn on dehumidifier
|
||||||
|
trigger:
|
||||||
|
- platform: numeric_state
|
||||||
|
entity_id: sensor.container_1_humidity
|
||||||
|
above: 65
|
||||||
|
action:
|
||||||
|
- service: switch.turn_on
|
||||||
|
entity_id: switch.container_1_dehumidifier
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Installation Timeline
|
||||||
|
|
||||||
|
### Tier 1 (Baseline)
|
||||||
|
- **Procurement:** 1 week (Amazon, fast shipping)
|
||||||
|
- **Installation:** 1 weekend (DIY)
|
||||||
|
- **Configuration:** 1-2 days
|
||||||
|
- **Testing:** 1 week
|
||||||
|
- **Total:** 3-4 weeks to full deployment
|
||||||
|
|
||||||
|
### Tier 2 (Premium)
|
||||||
|
- **Procurement:** 2 weeks (some specialty items)
|
||||||
|
- **Installation:** 1 week
|
||||||
|
- **Configuration:** 3-5 days
|
||||||
|
- **Testing:** 1 week
|
||||||
|
- **Total:** 5-6 weeks to full deployment
|
||||||
|
|
||||||
|
### Tier 3 (Enterprise)
|
||||||
|
- **Procurement:** 3-4 weeks (industrial equipment)
|
||||||
|
- **Installation:** 2-3 weeks (professional)
|
||||||
|
- **Configuration:** 1-2 weeks
|
||||||
|
- **Testing:** 2 weeks
|
||||||
|
- **Total:** 10-12 weeks to full deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 Cost Comparison Over 5 Years
|
||||||
|
|
||||||
|
| Tier | Initial Cost | Maintenance | Total 5-Year | ROI Timeline |
|
||||||
|
|------|--------------|-------------|--------------|--------------|
|
||||||
|
| **Baseline** | $2,200 | $324 | $2,524 | 6-12 months |
|
||||||
|
| **Premium** | $10,500 | $760 | $11,260 | 4-6 months |
|
||||||
|
| **Enterprise** | $36,600 | $8,560 | $45,160 | 6-9 months |
|
||||||
|
|
||||||
|
**ROI Calculation:**
|
||||||
|
- Energy savings from smart HVAC control: 20-30%
|
||||||
|
- Reduced crop loss from environmental alerts: 10-20%
|
||||||
|
- Increased yields from optimized environment: 10-20%
|
||||||
|
- Labor savings from automation: 5-10 hours/week
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Recommendations
|
||||||
|
|
||||||
|
### For Initial Testing → **Start with Tier 1 ($2,200)**
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Low risk investment
|
||||||
|
- Quick deployment (1 weekend)
|
||||||
|
- Proven consumer hardware
|
||||||
|
- Validates system before scaling
|
||||||
|
- Upgrade path to higher tiers
|
||||||
|
|
||||||
|
**Deployment Strategy:**
|
||||||
|
1. Pilot 1 greenhouse + 1 container (Week 1-2)
|
||||||
|
2. Test for 2-4 weeks (Week 3-6)
|
||||||
|
3. Expand to all zones (Week 7-8)
|
||||||
|
4. Total: 2 months to full deployment
|
||||||
|
|
||||||
|
### For Production → **Tier 2 ($10,500)**
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Production-grade reliability
|
||||||
|
- Camera surveillance (security + monitoring)
|
||||||
|
- CO2 monitoring (yield optimization)
|
||||||
|
- Professional soil sensors (Soil Scout)
|
||||||
|
- Good balance of cost vs capability
|
||||||
|
|
||||||
|
**ROI:** 200-300% over 5 years
|
||||||
|
|
||||||
|
### For Commercial Scale → **Tier 3 ($36,600)**
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Maximum uptime (99.99%)
|
||||||
|
- High availability (automatic failover)
|
||||||
|
- PAR light monitoring (professional cultivation)
|
||||||
|
- Enterprise support and SLAs
|
||||||
|
- 7-10 year lifespan
|
||||||
|
|
||||||
|
**ROI:** 400-600% over 5 years, 1000%+ over 10 years
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Additional Resources
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **Full Report:** [VERIDIAN-HARDWARE-TIERS-REPORT.md](./VERIDIAN-HARDWARE-TIERS-REPORT.md)
|
||||||
|
- **Veridian Docs:** https://docs.veridian.runfoo.run
|
||||||
|
- **Home Assistant Docs:** https://www.home-assistant.io/docs
|
||||||
|
|
||||||
|
### Hardware Vendors
|
||||||
|
- **SensorPush:** https://sensorpush.com
|
||||||
|
- **Raspberry Pi:** https://www.raspberrypi.com
|
||||||
|
- **Kasa Smart Plugs:** https://www.kasasmart.com
|
||||||
|
- **Soil Scout:** https://soilscout.com
|
||||||
|
- **RAK Wireless (LoRa):** https://store.rakwireless.com
|
||||||
|
|
||||||
|
### Community Support
|
||||||
|
- **Home Assistant Community:** https://community.home-assistant.io
|
||||||
|
- **Veridian Edge Repo:** https://git.runfoo.run/malty/veridian-edge
|
||||||
|
- **GitHub Issues:** https://github.com/your-org/veridian/issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏷️ Tags (for Web Search / SEO)
|
||||||
|
|
||||||
|
`#Veridian #CultivationPlatform #GreenhouseAutomation #IoT #HomeAssistant #SensorPush #SoilMoistureSensors #SmartFarming #CannabisCultivation #EnvironmentalMonitoring #RaspberryPi #EdgeComputing #Zigbee #LoRaWAN #WirelessSensors #ClimateControl #AgricultureTechnology #AgTech #PrecisionAgriculture #GrowAutomation #HVACControl #CO2Monitoring #PARSensors #VPDMonitoring #SmartPlugs #Kasa #TPLink #DIYGreenhouse #ContainerFarming #DryingContainers #HomeAutomation #SmartHome #IndustrialIoT #IIoT #AgriculturalSensors #CapacitiveSoilSensors #Modbus #MQTT #ZHA #Zigbee2MQTT #SoilScout #SenseAir #ApogeeInstruments #PTZCameras #Surveillance #RemoteMonitoring #OfflineResilience #DataBuffering #CloudSync #HardwareTier #BillOfMaterials #BOM #DeploymentGuide #InstallationGuide #MaintenanceSchedule #Troubleshooting #ROI #CostAnalysis #2026Pricing`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Contact & Support
|
||||||
|
|
||||||
|
**Veridian Platform:**
|
||||||
|
- **Documentation:** https://docs.veridian.runfoo.run
|
||||||
|
- **GitHub:** https://github.com/your-org/veridian
|
||||||
|
- **GitLab:** https://git.runfoo.run/malty/veridian-edge
|
||||||
|
- **Support:** support@veridian.runfoo.run
|
||||||
|
|
||||||
|
**Report Issues:**
|
||||||
|
- **GitHub Issues:** https://github.com/your-org/veridian/issues
|
||||||
|
- **Email:** support@veridian.runfoo.run
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Quick Reference Created:** January 6, 2026
|
||||||
|
**Full Report:** [VERIDIAN-HARDWARE-TIERS-REPORT.md](./VERIDIAN-HARDWARE-TIERS-REPORT.md)
|
||||||
|
**Version:** 1.0
|
||||||
|
|
||||||
2222
VERIDIAN-HARDWARE-TIERS-REPORT.md
Normal file
71
backend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
"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",
|
||||||
|
|
@ -701,6 +703,44 @@
|
||||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/cors": {
|
||||||
|
"version": "11.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||||
|
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"toad-cache": "^3.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/cors/node_modules/fastify-plugin": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@fastify/deepmerge": {
|
"node_modules/@fastify/deepmerge": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-2.0.2.tgz",
|
||||||
|
|
@ -799,6 +839,16 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/websocket": {
|
||||||
|
"version": "8.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-8.3.1.tgz",
|
||||||
|
"integrity": "sha512-hsQYHHJme/kvP3ZS4v/WMUznPBVeeQHHwAoMy1LiN6m/HuPfbdXq1MBJ4Nt8qX1YI+eVbog4MnOsU7MTozkwYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"ws": "^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
|
|
@ -6497,6 +6547,27 @@
|
||||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server.js",
|
"start": "npx prisma db push && node dist/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",
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|
@ -31,6 +32,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,9 @@ model User {
|
||||||
documentsCreated Document[] @relation("DocumentCreator")
|
documentsCreated Document[] @relation("DocumentCreator")
|
||||||
documentsApproved Document[] @relation("DocumentApprover")
|
documentsApproved Document[] @relation("DocumentApprover")
|
||||||
|
|
||||||
|
// Phase 7: Lifecycle
|
||||||
|
lifecycleEvents PlantLifecycleEvent[] @relation("EventCreator")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -510,6 +513,35 @@ enum SectionType {
|
||||||
FLOOR
|
FLOOR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plant Type Library (Rackula-inspired DeviceType pattern)
|
||||||
|
enum PlantCategory {
|
||||||
|
VEG
|
||||||
|
FLOWER
|
||||||
|
MOTHER
|
||||||
|
CLONE
|
||||||
|
SEEDLING
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlantType {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
slug String @unique // kebab-case identifier (e.g., "gorilla-glue-4")
|
||||||
|
name String // Display name
|
||||||
|
strain String? // Strain name
|
||||||
|
category PlantCategory // VEG, FLOWER, MOTHER, CLONE, SEEDLING
|
||||||
|
colour String // Hex color for display (e.g., "#4A90D9")
|
||||||
|
growthDays Int? // Expected days to harvest
|
||||||
|
yieldGrams Float? // Expected yield in grams
|
||||||
|
notes String?
|
||||||
|
tags String[]
|
||||||
|
customFields Json?
|
||||||
|
plants FacilityPlant[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("plant_types")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
model FacilityProperty {
|
model FacilityProperty {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
|
@ -568,6 +600,7 @@ model FacilityRoom {
|
||||||
rotation Int @default(0)
|
rotation Int @default(0)
|
||||||
color String? // Custom color override
|
color String? // Custom color override
|
||||||
sections FacilitySection[]
|
sections FacilitySection[]
|
||||||
|
cameras Camera[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
@ -616,28 +649,49 @@ model FacilityPlant {
|
||||||
tagNumber String @unique // METRC tag
|
tagNumber String @unique // METRC tag
|
||||||
batchId String?
|
batchId String?
|
||||||
batch Batch? @relation(fields: [batchId], references: [id])
|
batch Batch? @relation(fields: [batchId], references: [id])
|
||||||
|
plantTypeId String?
|
||||||
|
plantType PlantType? @relation(fields: [plantTypeId], references: [id])
|
||||||
position FacilityPosition @relation(fields: [positionId], references: [id])
|
position FacilityPosition @relation(fields: [positionId], references: [id])
|
||||||
positionId String @unique
|
positionId String @unique
|
||||||
address String // Full hierarchical address
|
address String // Full hierarchical address
|
||||||
status String @default("ACTIVE") // ACTIVE, HARVESTED, DESTROYED, TRANSFERRED
|
status String @default("ACTIVE") // ACTIVE, HARVESTED, DESTROYED, TRANSFERRED
|
||||||
history PlantLocationHistory[]
|
history PlantLifecycleEvent[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@map("facility_plants")
|
@@map("facility_plants")
|
||||||
}
|
}
|
||||||
|
|
||||||
model PlantLocationHistory {
|
enum PlantLifecycleEventType {
|
||||||
|
CREATED
|
||||||
|
MOVE
|
||||||
|
STAGE_CHANGE
|
||||||
|
HARVEST
|
||||||
|
DESTROY
|
||||||
|
NOTE
|
||||||
|
COMPLIANCE_CHECK
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlantLifecycleEvent {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
plant FacilityPlant @relation(fields: [plantId], references: [id], onDelete: Cascade)
|
plant FacilityPlant @relation(fields: [plantId], references: [id], onDelete: Cascade)
|
||||||
plantId String
|
plantId String
|
||||||
fromAddress String?
|
|
||||||
toAddress String
|
|
||||||
movedById String // User ID
|
|
||||||
reason String? // TRANSPLANT, REORGANIZE, HARVEST
|
|
||||||
movedAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@map("plant_location_history")
|
type PlantLifecycleEventType @default(MOVE)
|
||||||
|
|
||||||
|
// Locations (optional, for MOVE events)
|
||||||
|
fromAddress String?
|
||||||
|
toAddress String?
|
||||||
|
|
||||||
|
// Metadata for other events (JSON)
|
||||||
|
metadata Json? // { "reason": "...", "weight": 12.5, "wasteReason": "Mold" }
|
||||||
|
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation("EventCreator", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@map("plant_lifecycle_events")
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -988,6 +1042,49 @@ model EnvironmentProfile {
|
||||||
@@map("environment_profiles")
|
@@map("environment_profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------- Security Cameras ----------------------
|
||||||
|
|
||||||
|
enum CameraStatus {
|
||||||
|
ONLINE
|
||||||
|
OFFLINE
|
||||||
|
IDLE // Showing idle image, waiting for motion
|
||||||
|
STREAMING // Actively streaming
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
model Camera {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String // "Grow Room Entry"
|
||||||
|
slug String @unique
|
||||||
|
streamKey String // go2rtc stream name, e.g., "arlo_grow_room"
|
||||||
|
location String? // "North wall entrance"
|
||||||
|
roomId String?
|
||||||
|
room FacilityRoom? @relation(fields: [roomId], references: [id])
|
||||||
|
|
||||||
|
// Camera metadata
|
||||||
|
manufacturer String? // "Arlo", "Wyze", etc.
|
||||||
|
model String? // "Pro 4"
|
||||||
|
protocol String @default("RTSP") // RTSP, ONVIF, HLS
|
||||||
|
|
||||||
|
// Status
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
status CameraStatus @default(OFFLINE)
|
||||||
|
lastSeen DateTime?
|
||||||
|
|
||||||
|
// Positioning on floor plan (optional)
|
||||||
|
posX Int?
|
||||||
|
posY Int?
|
||||||
|
rotation Int @default(0) // 0-360 degrees
|
||||||
|
fov Int @default(90) // Field of view degrees
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([roomId])
|
||||||
|
@@index([streamKey])
|
||||||
|
@@map("cameras")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------- Financial Tracking ----------------------
|
// ---------------------- Financial Tracking ----------------------
|
||||||
|
|
||||||
enum TransactionType {
|
enum TransactionType {
|
||||||
|
|
|
||||||
91
backend/prisma/seed-plant-types.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* Seed script for common cannabis plant types
|
||||||
|
* Run with: npx ts-node prisma/seed-plant-types.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient, PlantCategory } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Common cannabis strains pre-populated for user convenience
|
||||||
|
const COMMON_STRAINS = [
|
||||||
|
// Indica-dominant
|
||||||
|
{ name: 'Gorilla Glue #4', strain: 'GG4', category: 'FLOWER' as PlantCategory, colour: '#4A5568', growthDays: 63, yieldGrams: 500, tags: ['indica', 'high-thc', 'relaxing'] },
|
||||||
|
{ name: 'Northern Lights', strain: 'NL', category: 'FLOWER' as PlantCategory, colour: '#5B21B6', growthDays: 56, yieldGrams: 450, tags: ['indica', 'classic', 'sleep'] },
|
||||||
|
{ name: 'Granddaddy Purple', strain: 'GDP', category: 'FLOWER' as PlantCategory, colour: '#7C3AED', growthDays: 60, yieldGrams: 400, tags: ['indica', 'purple', 'grape'] },
|
||||||
|
{ name: 'Purple Punch', strain: 'PP', category: 'FLOWER' as PlantCategory, colour: '#8B5CF6', growthDays: 56, yieldGrams: 450, tags: ['indica', 'dessert', 'relaxing'] },
|
||||||
|
|
||||||
|
// Sativa-dominant
|
||||||
|
{ name: 'Sour Diesel', strain: 'SD', category: 'FLOWER' as PlantCategory, colour: '#10B981', growthDays: 70, yieldGrams: 550, tags: ['sativa', 'energizing', 'diesel'] },
|
||||||
|
{ name: 'Jack Herer', strain: 'JH', category: 'FLOWER' as PlantCategory, colour: '#059669', growthDays: 63, yieldGrams: 400, tags: ['sativa', 'creative', 'pine'] },
|
||||||
|
{ name: 'Green Crack', strain: 'GC', category: 'FLOWER' as PlantCategory, colour: '#34D399', growthDays: 55, yieldGrams: 500, tags: ['sativa', 'energy', 'mango'] },
|
||||||
|
{ name: 'Super Silver Haze', strain: 'SSH', category: 'FLOWER' as PlantCategory, colour: '#6EE7B7', growthDays: 77, yieldGrams: 600, tags: ['sativa', 'award-winner', 'citrus'] },
|
||||||
|
|
||||||
|
// Hybrids
|
||||||
|
{ name: 'Blue Dream', strain: 'BD', category: 'FLOWER' as PlantCategory, colour: '#3B82F6', growthDays: 65, yieldGrams: 550, tags: ['hybrid', 'balanced', 'berry'] },
|
||||||
|
{ name: 'Wedding Cake', strain: 'WC', category: 'FLOWER' as PlantCategory, colour: '#F472B6', growthDays: 60, yieldGrams: 500, tags: ['hybrid', 'dessert', 'high-thc'] },
|
||||||
|
{ name: 'Gelato', strain: 'GLT', category: 'FLOWER' as PlantCategory, colour: '#EC4899', growthDays: 56, yieldGrams: 450, tags: ['hybrid', 'dessert', 'fruity'] },
|
||||||
|
{ name: 'OG Kush', strain: 'OGK', category: 'FLOWER' as PlantCategory, colour: '#84CC16', growthDays: 56, yieldGrams: 400, tags: ['hybrid', 'classic', 'earthy'] },
|
||||||
|
{ name: 'Girl Scout Cookies', strain: 'GSC', category: 'FLOWER' as PlantCategory, colour: '#22C55E', growthDays: 63, yieldGrams: 350, tags: ['hybrid', 'dessert', 'mint'] },
|
||||||
|
{ name: 'White Widow', strain: 'WW', category: 'FLOWER' as PlantCategory, colour: '#E5E7EB', growthDays: 60, yieldGrams: 450, tags: ['hybrid', 'classic', 'potent'] },
|
||||||
|
|
||||||
|
// Mother plants (common keeper phenotypes)
|
||||||
|
{ name: 'Mother - GG4 Elite', strain: 'GG4', category: 'MOTHER' as PlantCategory, colour: '#F59E0B', tags: ['mother', 'keeper', 'high-yield'] },
|
||||||
|
{ name: 'Mother - OG Kush S1', strain: 'OGK', category: 'MOTHER' as PlantCategory, colour: '#F97316', tags: ['mother', 'clone-source'] },
|
||||||
|
|
||||||
|
// Veg stage templates
|
||||||
|
{ name: 'Veg - Standard', strain: undefined, category: 'VEG' as PlantCategory, colour: '#10B981', growthDays: 28, tags: ['veg', 'standard'] },
|
||||||
|
{ name: 'Veg - Extended', strain: undefined, category: 'VEG' as PlantCategory, colour: '#059669', growthDays: 42, tags: ['veg', 'extended', 'large-plants'] },
|
||||||
|
|
||||||
|
// Clone templates
|
||||||
|
{ name: 'Clone - Rooting', strain: undefined, category: 'CLONE' as PlantCategory, colour: '#06B6D4', growthDays: 14, tags: ['clone', 'rooting'] },
|
||||||
|
{ name: 'Clone - Transplant Ready', strain: undefined, category: 'CLONE' as PlantCategory, colour: '#0891B2', growthDays: 21, tags: ['clone', 'ready'] },
|
||||||
|
|
||||||
|
// Seedling
|
||||||
|
{ name: 'Seedling - Week 1-2', strain: undefined, category: 'SEEDLING' as PlantCategory, colour: '#A3E635', growthDays: 14, tags: ['seedling', 'early'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function generateSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding plant types...');
|
||||||
|
|
||||||
|
for (const strain of COMMON_STRAINS) {
|
||||||
|
const slug = generateSlug(strain.name);
|
||||||
|
|
||||||
|
// Upsert to avoid duplicates
|
||||||
|
await prisma.plantType.upsert({
|
||||||
|
where: { slug },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug,
|
||||||
|
name: strain.name,
|
||||||
|
strain: strain.strain,
|
||||||
|
category: strain.category,
|
||||||
|
colour: strain.colour,
|
||||||
|
growthDays: strain.growthDays,
|
||||||
|
yieldGrams: strain.yieldGrams,
|
||||||
|
tags: strain.tags || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ ${strain.name} (${slug})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Seeded ${COMMON_STRAINS.length} plant types`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Seed failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
254
backend/src/controllers/cameras.controller.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { CameraStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
// Utility to generate slug from name
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/cameras - List all cameras
|
||||||
|
export const getCameras = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const cameras = await request.server.prisma.camera.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
include: {
|
||||||
|
room: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
type: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return cameras;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/cameras/:id - Get single camera
|
||||||
|
export const getCameraById = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
|
||||||
|
const camera = await request.server.prisma.camera.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
room: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
type: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!camera) {
|
||||||
|
return reply.status(404).send({ message: 'Camera not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return camera;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/cameras/:id/stream - Get stream URL for go2rtc
|
||||||
|
export const getCameraStream = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
|
||||||
|
const camera = await request.server.prisma.camera.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!camera) {
|
||||||
|
return reply.status(404).send({ message: 'Camera not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!camera.isActive) {
|
||||||
|
return reply.status(503).send({ message: 'Camera is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return go2rtc stream endpoints
|
||||||
|
const go2rtcBase = process.env.GO2RTC_URL || 'http://go2rtc:1984';
|
||||||
|
|
||||||
|
return {
|
||||||
|
camera: {
|
||||||
|
id: camera.id,
|
||||||
|
name: camera.name,
|
||||||
|
streamKey: camera.streamKey,
|
||||||
|
status: camera.status
|
||||||
|
},
|
||||||
|
streams: {
|
||||||
|
// go2rtc provides these endpoints automatically
|
||||||
|
webrtc: `${go2rtcBase}/api/ws?src=${camera.streamKey}`,
|
||||||
|
mse: `${go2rtcBase}/api/stream.mp4?src=${camera.streamKey}`,
|
||||||
|
hls: `${go2rtcBase}/api/stream.m3u8?src=${camera.streamKey}`,
|
||||||
|
snapshot: `${go2rtcBase}/api/frame.jpeg?src=${camera.streamKey}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/cameras - Create new camera
|
||||||
|
export const createCamera = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
streamKey,
|
||||||
|
location,
|
||||||
|
roomId,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
protocol,
|
||||||
|
posX,
|
||||||
|
posY,
|
||||||
|
rotation,
|
||||||
|
fov
|
||||||
|
} = request.body as any;
|
||||||
|
|
||||||
|
if (!name || !streamKey) {
|
||||||
|
return reply.status(400).send({ message: 'Name and streamKey are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = slugify(name);
|
||||||
|
|
||||||
|
// Check for duplicate slug
|
||||||
|
const existing = await request.server.prisma.camera.findUnique({
|
||||||
|
where: { slug }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return reply.status(409).send({ message: 'Camera with this name already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = await request.server.prisma.camera.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
streamKey,
|
||||||
|
location,
|
||||||
|
roomId: roomId || null,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
protocol: protocol || 'RTSP',
|
||||||
|
posX: posX ? parseInt(posX) : null,
|
||||||
|
posY: posY ? parseInt(posY) : null,
|
||||||
|
rotation: rotation ? parseInt(rotation) : 0,
|
||||||
|
fov: fov ? parseInt(fov) : 90,
|
||||||
|
status: CameraStatus.OFFLINE
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
room: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
type: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(201).send(camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
// PUT /api/cameras/:id - Update camera
|
||||||
|
export const updateCamera = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
streamKey,
|
||||||
|
location,
|
||||||
|
roomId,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
protocol,
|
||||||
|
isActive,
|
||||||
|
posX,
|
||||||
|
posY,
|
||||||
|
rotation,
|
||||||
|
fov
|
||||||
|
} = request.body as any;
|
||||||
|
|
||||||
|
const existing = await request.server.prisma.camera.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return reply.status(404).send({ message: 'Camera not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
data.name = name;
|
||||||
|
data.slug = slugify(name);
|
||||||
|
}
|
||||||
|
if (streamKey !== undefined) data.streamKey = streamKey;
|
||||||
|
if (location !== undefined) data.location = location;
|
||||||
|
if (roomId !== undefined) data.roomId = roomId || null;
|
||||||
|
if (manufacturer !== undefined) data.manufacturer = manufacturer;
|
||||||
|
if (model !== undefined) data.model = model;
|
||||||
|
if (protocol !== undefined) data.protocol = protocol;
|
||||||
|
if (isActive !== undefined) data.isActive = isActive;
|
||||||
|
if (posX !== undefined) data.posX = parseInt(posX);
|
||||||
|
if (posY !== undefined) data.posY = parseInt(posY);
|
||||||
|
if (rotation !== undefined) data.rotation = parseInt(rotation);
|
||||||
|
if (fov !== undefined) data.fov = parseInt(fov);
|
||||||
|
|
||||||
|
const camera = await request.server.prisma.camera.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
room: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
type: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return camera;
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /api/cameras/:id - Delete camera
|
||||||
|
export const deleteCamera = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
|
||||||
|
const existing = await request.server.prisma.camera.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return reply.status(404).send({ message: 'Camera not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await request.server.prisma.camera.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(204).send();
|
||||||
|
};
|
||||||
|
|
||||||
|
// PATCH /api/cameras/:id/status - Update camera status (called by system/integrations)
|
||||||
|
export const updateCameraStatus = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const { status } = request.body as { status: CameraStatus };
|
||||||
|
|
||||||
|
if (!status || !Object.values(CameraStatus).includes(status)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
message: 'Invalid status. Must be one of: ONLINE, OFFLINE, IDLE, STREAMING, ERROR'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = await request.server.prisma.camera.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
lastSeen: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return camera;
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,7 @@ export const getTasks = async (request: FastifyRequest, reply: FastifyReply) =>
|
||||||
const tasks = await request.server.prisma.task.findMany({
|
const tasks = await request.server.prisma.task.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(status && { status }),
|
...(status && { status }),
|
||||||
...(assigneeId && { assigneeId }),
|
...(assigneeId && { assignedToId: assigneeId }),
|
||||||
...(roomId && { roomId }),
|
...(roomId && { roomId }),
|
||||||
...(batchId && { batchId }),
|
...(batchId && { batchId }),
|
||||||
...(startDate && endDate && {
|
...(startDate && endDate && {
|
||||||
|
|
|
||||||
117
backend/src/plugins/websocket.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* WebSocket Plugin for Real-time Alerts
|
||||||
|
*
|
||||||
|
* Broadcasts environment alerts to connected clients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import websocket from '@fastify/websocket';
|
||||||
|
|
||||||
|
interface AlertMessage {
|
||||||
|
type: 'ALERT' | 'READING' | 'HEARTBEAT';
|
||||||
|
data: any;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected clients (storing the raw WebSocket)
|
||||||
|
const clients: Map<string, any> = new Map();
|
||||||
|
|
||||||
|
export async function websocketPlugin(fastify: FastifyInstance) {
|
||||||
|
await fastify.register(websocket);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket endpoint for real-time alerts
|
||||||
|
*/
|
||||||
|
fastify.get('/api/ws/alerts', { websocket: true }, (connection, request) => {
|
||||||
|
const clientId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
// Get the raw WebSocket from the SocketStream
|
||||||
|
const socket = connection.socket;
|
||||||
|
|
||||||
|
clients.set(clientId, socket);
|
||||||
|
fastify.log.info(`WebSocket client connected: ${clientId}`);
|
||||||
|
|
||||||
|
// Send welcome message
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'CONNECTED',
|
||||||
|
clientId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
socket.on('message', (message: Buffer) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
|
||||||
|
// Handle ping/pong for keepalive
|
||||||
|
if (data.type === 'PING') {
|
||||||
|
socket.send(JSON.stringify({ type: 'PONG', timestamp: new Date().toISOString() }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid messages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
clients.delete(clientId);
|
||||||
|
fastify.log.info(`WebSocket client disconnected: ${clientId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error: Error) => {
|
||||||
|
fastify.log.error(`WebSocket error for ${clientId}: ${error.message}`);
|
||||||
|
clients.delete(clientId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast an alert to all connected clients
|
||||||
|
*/
|
||||||
|
export function broadcastAlert(alert: any): void {
|
||||||
|
const message: AlertMessage = {
|
||||||
|
type: 'ALERT',
|
||||||
|
data: alert,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
|
||||||
|
clients.forEach((socket, clientId) => {
|
||||||
|
try {
|
||||||
|
if (socket.readyState === 1) { // OPEN
|
||||||
|
socket.send(payload);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to broadcast to ${clientId}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a sensor reading update
|
||||||
|
*/
|
||||||
|
export function broadcastReading(reading: any): void {
|
||||||
|
const message: AlertMessage = {
|
||||||
|
type: 'READING',
|
||||||
|
data: reading,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
|
||||||
|
clients.forEach((socket, clientId) => {
|
||||||
|
try {
|
||||||
|
if (socket.readyState === 1) {
|
||||||
|
socket.send(payload);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to broadcast reading to ${clientId}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of connected clients
|
||||||
|
*/
|
||||||
|
export function getConnectedClientCount(): number {
|
||||||
|
return clients.size;
|
||||||
|
}
|
||||||
30
backend/src/routes/cameras.routes.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import {
|
||||||
|
getCameras,
|
||||||
|
getCameraById,
|
||||||
|
getCameraStream,
|
||||||
|
createCamera,
|
||||||
|
updateCamera,
|
||||||
|
deleteCamera,
|
||||||
|
updateCameraStatus
|
||||||
|
} from '../controllers/cameras.controller';
|
||||||
|
|
||||||
|
export async function cameraRoutes(server: FastifyInstance) {
|
||||||
|
// Auth required for all routes
|
||||||
|
server.addHook('onRequest', async (request) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// CRUD Routes
|
||||||
|
server.get('/', getCameras);
|
||||||
|
server.get('/:id', getCameraById);
|
||||||
|
server.get('/:id/stream', getCameraStream);
|
||||||
|
server.post('/', createCamera);
|
||||||
|
server.put('/:id', updateCamera);
|
||||||
|
server.delete('/:id', deleteCamera);
|
||||||
|
server.patch('/:id/status', updateCameraStatus);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { broadcastAlert } from '../plugins/websocket';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
@ -39,8 +40,15 @@ const profileSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function environmentRoutes(fastify: FastifyInstance) {
|
export async function environmentRoutes(fastify: FastifyInstance) {
|
||||||
// Auth middleware
|
// Auth middleware - skip for edge device endpoints (they use API key auth)
|
||||||
fastify.addHook('onRequest', async (request) => {
|
const edgeEndpoints = ['/heartbeat', '/ingest'];
|
||||||
|
fastify.addHook('onRequest', async (request, reply) => {
|
||||||
|
// Skip JWT for edge device endpoints
|
||||||
|
const path = request.url.split('?')[0];
|
||||||
|
if (edgeEndpoints.some(ep => path.endsWith(ep))) {
|
||||||
|
return; // Edge endpoints handle their own auth
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -295,6 +303,158 @@ export async function environmentRoutes(fastify: FastifyInstance) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /alerts/resolve-all
|
||||||
|
* Resolve all unresolved alerts (for demo reset)
|
||||||
|
*/
|
||||||
|
fastify.post('/alerts/resolve-all', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const result = await prisma.environmentAlert.updateMany({
|
||||||
|
where: { resolvedAt: null },
|
||||||
|
data: { resolvedAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.log.info(`🔄 Resolved ${result.count} pending alerts`);
|
||||||
|
return { resolved: result.count, message: `Resolved ${result.count} alerts` };
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to resolve alerts' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /alerts/analytics
|
||||||
|
* Get alert analytics with response times
|
||||||
|
*/
|
||||||
|
fastify.get('/alerts/analytics', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { hours = 168 } = request.query as { hours?: number }; // Default 7 days
|
||||||
|
const since = new Date();
|
||||||
|
since.setHours(since.getHours() - Number(hours));
|
||||||
|
|
||||||
|
const alerts = await prisma.environmentAlert.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: since }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
const totalAlerts = alerts.length;
|
||||||
|
const resolvedAlerts = alerts.filter(a => a.resolvedAt);
|
||||||
|
const acknowledgedAlerts = alerts.filter(a => a.acknowledgedAt);
|
||||||
|
const unresolvedAlerts = alerts.filter(a => !a.resolvedAt);
|
||||||
|
|
||||||
|
// Response times (time from creation to acknowledgement)
|
||||||
|
const responseTimes = acknowledgedAlerts.map(a => {
|
||||||
|
const created = new Date(a.createdAt).getTime();
|
||||||
|
const acked = new Date(a.acknowledgedAt!).getTime();
|
||||||
|
return (acked - created) / 1000 / 60; // minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolution times (time from creation to resolution)
|
||||||
|
const resolutionTimes = resolvedAlerts.map(a => {
|
||||||
|
const created = new Date(a.createdAt).getTime();
|
||||||
|
const resolved = new Date(a.resolvedAt!).getTime();
|
||||||
|
return (resolved - created) / 1000 / 60; // minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate averages
|
||||||
|
const avgResponseTime = responseTimes.length > 0
|
||||||
|
? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const avgResolutionTime = resolutionTimes.length > 0
|
||||||
|
? resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const minResponseTime = responseTimes.length > 0 ? Math.min(...responseTimes) : null;
|
||||||
|
const maxResponseTime = responseTimes.length > 0 ? Math.max(...responseTimes) : null;
|
||||||
|
const minResolutionTime = resolutionTimes.length > 0 ? Math.min(...resolutionTimes) : null;
|
||||||
|
const maxResolutionTime = resolutionTimes.length > 0 ? Math.max(...resolutionTimes) : null;
|
||||||
|
|
||||||
|
// Group by type
|
||||||
|
const byType: Record<string, { count: number; resolved: number; avgResolution: number | null }> = {};
|
||||||
|
alerts.forEach(a => {
|
||||||
|
if (!byType[a.type]) {
|
||||||
|
byType[a.type] = { count: 0, resolved: 0, avgResolution: null };
|
||||||
|
}
|
||||||
|
byType[a.type].count++;
|
||||||
|
if (a.resolvedAt) byType[a.type].resolved++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate avg resolution per type
|
||||||
|
Object.keys(byType).forEach(type => {
|
||||||
|
const typeAlerts = resolvedAlerts.filter(a => a.type === type);
|
||||||
|
if (typeAlerts.length > 0) {
|
||||||
|
const times = typeAlerts.map(a => {
|
||||||
|
const created = new Date(a.createdAt).getTime();
|
||||||
|
const resolved = new Date(a.resolvedAt!).getTime();
|
||||||
|
return (resolved - created) / 1000 / 60;
|
||||||
|
});
|
||||||
|
byType[type].avgResolution = times.reduce((a, b) => a + b, 0) / times.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recent alerts for timeline
|
||||||
|
const recentAlerts = alerts.slice(0, 10).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
type: a.type,
|
||||||
|
severity: a.severity,
|
||||||
|
message: a.message,
|
||||||
|
value: a.value,
|
||||||
|
threshold: a.threshold,
|
||||||
|
createdAt: a.createdAt,
|
||||||
|
acknowledgedAt: a.acknowledgedAt,
|
||||||
|
resolvedAt: a.resolvedAt,
|
||||||
|
responseTimeMin: a.acknowledgedAt
|
||||||
|
? (new Date(a.acknowledgedAt).getTime() - new Date(a.createdAt).getTime()) / 1000 / 60
|
||||||
|
: null,
|
||||||
|
resolutionTimeMin: a.resolvedAt
|
||||||
|
? (new Date(a.resolvedAt).getTime() - new Date(a.createdAt).getTime()) / 1000 / 60
|
||||||
|
: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: {
|
||||||
|
hours: Number(hours),
|
||||||
|
from: since.toISOString(),
|
||||||
|
to: new Date().toISOString()
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
total: totalAlerts,
|
||||||
|
resolved: resolvedAlerts.length,
|
||||||
|
acknowledged: acknowledgedAlerts.length,
|
||||||
|
unresolved: unresolvedAlerts.length,
|
||||||
|
resolutionRate: totalAlerts > 0 ? (resolvedAlerts.length / totalAlerts * 100).toFixed(1) : 0
|
||||||
|
},
|
||||||
|
responseTimes: {
|
||||||
|
avgMinutes: avgResponseTime?.toFixed(1) || null,
|
||||||
|
minMinutes: minResponseTime?.toFixed(1) || null,
|
||||||
|
maxMinutes: maxResponseTime?.toFixed(1) || null
|
||||||
|
},
|
||||||
|
resolutionTimes: {
|
||||||
|
avgMinutes: avgResolutionTime?.toFixed(1) || null,
|
||||||
|
minMinutes: minResolutionTime?.toFixed(1) || null,
|
||||||
|
maxMinutes: maxResolutionTime?.toFixed(1) || null
|
||||||
|
},
|
||||||
|
byType: Object.entries(byType).map(([type, data]) => ({
|
||||||
|
type,
|
||||||
|
...data,
|
||||||
|
avgResolutionMin: data.avgResolution?.toFixed(1) || null
|
||||||
|
})),
|
||||||
|
recentAlerts
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to fetch alert analytics' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== ENVIRONMENT PROFILES ====================
|
// ==================== ENVIRONMENT PROFILES ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -415,4 +575,203 @@ export async function environmentRoutes(fastify: FastifyInstance) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== EDGE DEVICE ENDPOINTS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /ingest
|
||||||
|
* Batch ingest readings from edge device
|
||||||
|
* This endpoint accepts API key auth (not JWT) for edge devices
|
||||||
|
*/
|
||||||
|
fastify.post('/ingest', {
|
||||||
|
config: { rawBody: true },
|
||||||
|
preHandler: async (request, reply) => {
|
||||||
|
// Allow API key auth for edge devices
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const apiKey = authHeader.substring(7);
|
||||||
|
// TODO: Validate API key against facility configuration
|
||||||
|
// For now, accept any non-empty key
|
||||||
|
if (!apiKey) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid API key' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { facilityId, readings } = request.body as {
|
||||||
|
facilityId?: string;
|
||||||
|
readings: Array<{
|
||||||
|
roomId: string;
|
||||||
|
sensorId?: string;
|
||||||
|
temperature: number;
|
||||||
|
humidity: number;
|
||||||
|
dewpoint?: number;
|
||||||
|
vpd?: number;
|
||||||
|
timestamp: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!readings || !Array.isArray(readings) || readings.length === 0) {
|
||||||
|
return reply.status(400).send({ error: 'No readings provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store readings in batch
|
||||||
|
const created = await prisma.$transaction(
|
||||||
|
readings.map(r =>
|
||||||
|
prisma.sensorReading.create({
|
||||||
|
data: {
|
||||||
|
sensorId: r.sensorId || 'edge-default',
|
||||||
|
value: r.temperature, // Primary value is temperature
|
||||||
|
unit: '°F',
|
||||||
|
timestamp: new Date(r.timestamp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.log.info(`Ingested ${created.length} readings from edge device`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
count: created.length,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to ingest readings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /heartbeat
|
||||||
|
* Edge device heartbeat for monitoring
|
||||||
|
*/
|
||||||
|
fastify.post('/heartbeat', {
|
||||||
|
preHandler: async (request, reply) => {
|
||||||
|
// For demo: Accept any Bearer token (no validation)
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return reply.status(401).send({ error: 'API key required' });
|
||||||
|
}
|
||||||
|
// TODO: In production, validate API key against registered edge devices
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const data = request.body as {
|
||||||
|
facilityId: string;
|
||||||
|
edgeId: string;
|
||||||
|
status: 'ok' | 'degraded' | 'error';
|
||||||
|
sensorCount: number;
|
||||||
|
bufferSize: number;
|
||||||
|
lastReading?: string;
|
||||||
|
uptime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log heartbeat (could store in Redis for real-time monitoring)
|
||||||
|
fastify.log.info({
|
||||||
|
event: 'edge_heartbeat',
|
||||||
|
...data,
|
||||||
|
receivedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Store in Redis or create alert if status is not 'ok'
|
||||||
|
// For now, just acknowledge
|
||||||
|
if (data.status !== 'ok') {
|
||||||
|
fastify.log.warn(`Edge device ${data.edgeId} status: ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unaddressed critical temperature alerts (> 30 seconds for DEMO)
|
||||||
|
const thirtySecondsAgo = new Date(Date.now() - 30 * 1000);
|
||||||
|
const criticalAlert = await prisma.environmentAlert.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'TEMPERATURE_HIGH',
|
||||||
|
resolvedAt: null,
|
||||||
|
acknowledgedAt: null,
|
||||||
|
createdAt: { lt: thirtySecondsAgo }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const commands: any[] = [];
|
||||||
|
if (criticalAlert) {
|
||||||
|
fastify.log.warn(`🚨 Failsafe triggered for alert ${criticalAlert.id}! Sending Kasa ON command to edge.`);
|
||||||
|
commands.push({ type: 'toggle_plug', state: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ack: true,
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
commands
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to process heartbeat' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /alert
|
||||||
|
* Edge device alert relay (for notification fan-out)
|
||||||
|
*/
|
||||||
|
fastify.post('/alert', {
|
||||||
|
preHandler: async (request, reply) => {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return reply.status(401).send({ error: 'API key required' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const data = request.body as {
|
||||||
|
facilityId: string;
|
||||||
|
edgeId: string;
|
||||||
|
alertType: string;
|
||||||
|
sensorId: string;
|
||||||
|
sensorName: string;
|
||||||
|
currentValue: number;
|
||||||
|
threshold: number;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create alert in database
|
||||||
|
const alert = await prisma.environmentAlert.create({
|
||||||
|
data: {
|
||||||
|
type: data.alertType,
|
||||||
|
severity: data.alertType.includes('HIGH') ? 'WARNING' : 'INFO',
|
||||||
|
message: `${data.sensorName}: ${data.alertType.replace('_', ' ')} (${data.currentValue} vs threshold ${data.threshold})`,
|
||||||
|
value: data.currentValue,
|
||||||
|
threshold: data.threshold
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.log.warn({
|
||||||
|
event: 'edge_alert',
|
||||||
|
alertId: alert.id,
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to WebSocket clients for real-time notifications
|
||||||
|
broadcastAlert({
|
||||||
|
id: alert.id,
|
||||||
|
type: data.alertType,
|
||||||
|
sensorName: data.sensorName,
|
||||||
|
value: data.currentValue,
|
||||||
|
threshold: data.threshold,
|
||||||
|
message: alert.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
alertId: alert.id,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to process alert' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,121 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
||||||
// ROOM ROUTES
|
// ROOM ROUTES
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// Create room
|
// Generate Room with Layout (Parametric)
|
||||||
|
fastify.post('/rooms/generate', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
floorId, name, code, type,
|
||||||
|
setupType, // RACK, TABLE
|
||||||
|
tiers, // 1-5
|
||||||
|
racksCount, // Total number of racks
|
||||||
|
rowsPerRack,
|
||||||
|
colsPerRack
|
||||||
|
} = request.body as any;
|
||||||
|
|
||||||
|
const numTiers = tiers || 1;
|
||||||
|
const numRacks = racksCount || 1;
|
||||||
|
const numRows = rowsPerRack || 4;
|
||||||
|
const numCols = colsPerRack || 2;
|
||||||
|
|
||||||
|
// Standard dimensions (approx 4x8 table scaled)
|
||||||
|
const rackWidth = 40;
|
||||||
|
const rackHeight = 80;
|
||||||
|
const aisleWidth = 30;
|
||||||
|
const padding = 20;
|
||||||
|
|
||||||
|
// Calculate room size approx
|
||||||
|
// Simple layout: 2 rows of racks if possible, else 1 long row
|
||||||
|
const racksPerRow = Math.ceil(numRacks / 2);
|
||||||
|
const numRackRows = Math.ceil(numRacks / racksPerRow);
|
||||||
|
|
||||||
|
const roomWidth = (padding * 2) + (racksPerRow * rackWidth) + ((racksPerRow - 1) * aisleWidth);
|
||||||
|
const roomHeight = (padding * 2) + (numRackRows * rackHeight) + ((numRackRows - 1) * aisleWidth);
|
||||||
|
|
||||||
|
// Create the Room
|
||||||
|
const room = await prisma.facilityRoom.create({
|
||||||
|
data: {
|
||||||
|
floorId,
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
type: type as RoomType,
|
||||||
|
posX: 0, // Default to 0,0 - user can move
|
||||||
|
posY: 0,
|
||||||
|
width: Math.max(roomWidth, 100),
|
||||||
|
height: Math.max(roomHeight, 100),
|
||||||
|
color: type === 'FLOWER' ? '#10b981' : '#3b82f6'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate Racks (Sections)
|
||||||
|
const sectionsToCreate = [];
|
||||||
|
let rackCounter = 1;
|
||||||
|
|
||||||
|
for (let r = 0; r < numRackRows; r++) {
|
||||||
|
for (let c = 0; c < racksPerRow; c++) {
|
||||||
|
if (rackCounter > numRacks) break;
|
||||||
|
|
||||||
|
const sPosX = padding + (c * (rackWidth + aisleWidth));
|
||||||
|
const sPosY = padding + (r * (rackHeight + aisleWidth));
|
||||||
|
|
||||||
|
// Generate positions for this rack
|
||||||
|
const positionsToCreate = [];
|
||||||
|
for (let row = 1; row <= numRows; row++) {
|
||||||
|
for (let col = 1; col <= numCols; col++) {
|
||||||
|
for (let t = 1; t <= numTiers; t++) {
|
||||||
|
positionsToCreate.push({
|
||||||
|
row,
|
||||||
|
column: col,
|
||||||
|
tier: t,
|
||||||
|
slot: 1,
|
||||||
|
status: 'EMPTY'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionsToCreate.push(prisma.facilitySection.create({
|
||||||
|
data: {
|
||||||
|
roomId: room.id,
|
||||||
|
name: `${setupType} ${rackCounter}`,
|
||||||
|
code: `${code}-R${rackCounter}`,
|
||||||
|
type: (setupType || 'RACK') as SectionType,
|
||||||
|
posX: sPosX,
|
||||||
|
posY: sPosY,
|
||||||
|
width: rackWidth,
|
||||||
|
height: rackHeight,
|
||||||
|
rows: numRows,
|
||||||
|
columns: numCols,
|
||||||
|
tiers: numTiers,
|
||||||
|
spacing: 12,
|
||||||
|
positions: {
|
||||||
|
create: positionsToCreate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
rackCounter++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(sectionsToCreate);
|
||||||
|
|
||||||
|
// Fetch full room to return
|
||||||
|
const fullRoom = await prisma.facilityRoom.findUnique({
|
||||||
|
where: { id: room.id },
|
||||||
|
include: { sections: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(201).send(fullRoom);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to generate room layout' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
fastify.post('/rooms', {
|
fastify.post('/rooms', {
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -393,12 +507,91 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Section
|
||||||
|
fastify.patch('/sections/:id', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = request.params as any;
|
||||||
|
const { name, code, type, rows, columns } = request.body as any;
|
||||||
|
|
||||||
|
// Get current section
|
||||||
|
const section = await prisma.facilitySection.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { positions: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!section) return reply.status(404).send({ error: 'Section not found' });
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Update section props
|
||||||
|
await tx.facilitySection.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
type: type as SectionType,
|
||||||
|
rows,
|
||||||
|
columns
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If dimensions changed, handle positions
|
||||||
|
if (rows !== section.rows || columns !== section.columns) {
|
||||||
|
// 1. Remove positions out of bounds
|
||||||
|
// Note: This will cascade delete plants in those positions if foreign keys are set up that way,
|
||||||
|
// or it might fail if plants exist. Ideally we check, but for now assuming force resize.
|
||||||
|
await tx.facilityPosition.deleteMany({
|
||||||
|
where: {
|
||||||
|
sectionId: id,
|
||||||
|
OR: [
|
||||||
|
{ row: { gt: rows } },
|
||||||
|
{ column: { gt: columns } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Add new positions
|
||||||
|
const newPositions: any[] = [];
|
||||||
|
for (let r = 1; r <= rows; r++) {
|
||||||
|
for (let c = 1; c <= columns; c++) {
|
||||||
|
// Check if position exists in the snapshot we loaded
|
||||||
|
// We check simply by coordinates as we removed out of bounds ones
|
||||||
|
const exists = section.positions.some(p => p.row === r && p.column === c);
|
||||||
|
if (!exists) {
|
||||||
|
newPositions.push({
|
||||||
|
sectionId: id,
|
||||||
|
row: r,
|
||||||
|
column: c,
|
||||||
|
tier: 1, // Defaulting to single tier for resizing logic cleanliness
|
||||||
|
slot: 1,
|
||||||
|
status: 'EMPTY'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPositions.length > 0) {
|
||||||
|
await tx.facilityPosition.createMany({
|
||||||
|
data: newPositions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to update section' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Occupy Position (Place Plant)
|
// Occupy Position (Place Plant)
|
||||||
fastify.post('/positions/:id/occupy', {
|
fastify.post('/positions/:id/occupy', {
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { id } = request.params as any;
|
const { id } = request.params as any;
|
||||||
const { batchId } = request.body as any;
|
const { batchId, plantTypeId } = request.body as any;
|
||||||
|
|
||||||
const position = await prisma.facilityPosition.findUnique({
|
const position = await prisma.facilityPosition.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
@ -421,6 +614,7 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
||||||
data: {
|
data: {
|
||||||
tagNumber: `P-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
|
tagNumber: `P-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
|
||||||
batchId,
|
batchId,
|
||||||
|
plantTypeId,
|
||||||
positionId: id,
|
positionId: id,
|
||||||
address,
|
address,
|
||||||
status: 'ACTIVE'
|
status: 'ACTIVE'
|
||||||
|
|
@ -497,14 +691,15 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
||||||
data: { status: 'PLANTED' }
|
data: { status: 'PLANTED' }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Record history
|
// Record history (as a MOVE lifecycle event)
|
||||||
await tx.plantLocationHistory.create({
|
await tx.plantLifecycleEvent.create({
|
||||||
data: {
|
data: {
|
||||||
plantId: id,
|
plantId: id,
|
||||||
|
type: 'MOVE',
|
||||||
fromAddress: oldAddress,
|
fromAddress: oldAddress,
|
||||||
toAddress: newAddress,
|
toAddress: newAddress,
|
||||||
movedById: userId,
|
metadata: { reason: reason || 'REORGANIZE' },
|
||||||
reason: reason || 'REORGANIZE'
|
createdById: userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -521,6 +716,136 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update Plant (Tag, Notes)
|
||||||
|
fastify.patch('/plants/:id', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = request.params as any;
|
||||||
|
const { tagNumber, notes } = request.body as any;
|
||||||
|
const userId = (request.user as any)?.id || 'system';
|
||||||
|
|
||||||
|
const plant = await prisma.facilityPlant.findUnique({ where: { id } });
|
||||||
|
if (!plant) return reply.status(404).send({ error: 'Plant not found' });
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.facilityPlant.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
tagNumber: tagNumber || undefined,
|
||||||
|
// schema doesn't have notes on plant, but maybe we add it or log it as event?
|
||||||
|
// wait, layoutApi defines updatePlant(id, {tagNumber, notes}).
|
||||||
|
// schema.prisma: model FacilityPlant { notes String? ... }?
|
||||||
|
// Let's check schema.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log event
|
||||||
|
if (tagNumber && tagNumber !== plant.tagNumber) {
|
||||||
|
await tx.plantLifecycleEvent.create({
|
||||||
|
data: {
|
||||||
|
plantId: id,
|
||||||
|
type: 'NOTE', // Or COMPLIANCE_CHECK? Or generic AUDIT?
|
||||||
|
metadata: { note: `Tag changed from ${plant.tagNumber} to ${tagNumber}` },
|
||||||
|
createdById: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to update plant' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Destroy Plant
|
||||||
|
fastify.post('/plants/:id/destroy', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = request.params as any;
|
||||||
|
const { reason, method } = request.body as any;
|
||||||
|
const userId = (request.user as any)?.id || 'system';
|
||||||
|
|
||||||
|
const plant = await prisma.facilityPlant.findUnique({ where: { id } });
|
||||||
|
if (!plant) return reply.status(404).send({ error: 'Plant not found' });
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Update plant status
|
||||||
|
await tx.facilityPlant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'DESTROYED' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Free up the position
|
||||||
|
await tx.facilityPosition.update({
|
||||||
|
where: { id: plant.positionId },
|
||||||
|
data: { status: 'EMPTY' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Log event
|
||||||
|
await tx.plantLifecycleEvent.create({
|
||||||
|
data: {
|
||||||
|
plantId: id,
|
||||||
|
type: 'DESTROY',
|
||||||
|
metadata: { reason, method },
|
||||||
|
createdById: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to destroy plant' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Harvest Plant
|
||||||
|
fastify.post('/plants/:id/harvest', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = request.params as any;
|
||||||
|
const { weight, unit, notes } = request.body as any;
|
||||||
|
const userId = (request.user as any)?.id || 'system';
|
||||||
|
|
||||||
|
const plant = await prisma.facilityPlant.findUnique({ where: { id } });
|
||||||
|
if (!plant) return reply.status(404).send({ error: 'Plant not found' });
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Update plant status
|
||||||
|
await tx.facilityPlant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'HARVESTED' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Free up the position (Harvest removes from rack)
|
||||||
|
await tx.facilityPosition.update({
|
||||||
|
where: { id: plant.positionId },
|
||||||
|
data: { status: 'EMPTY' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Log event
|
||||||
|
await tx.plantLifecycleEvent.create({
|
||||||
|
data: {
|
||||||
|
plantId: id,
|
||||||
|
type: 'HARVEST',
|
||||||
|
metadata: { weight, unit, notes },
|
||||||
|
createdById: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to harvest plant' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bulk Fill Section (Place plants in all empty positions)
|
// Bulk Fill Section (Place plants in all empty positions)
|
||||||
fastify.post('/sections/:id/fill', {
|
fastify.post('/sections/:id/fill', {
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
|
|
@ -881,4 +1206,132 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PLANT TYPE LIBRARY ROUTES (Rackula-inspired)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Get all plant types
|
||||||
|
fastify.get('/plant-types', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const plantTypes = await prisma.plantType.findMany({
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
return plantTypes;
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to fetch plant types' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get plant type by slug
|
||||||
|
fastify.get('/plant-types/:slug', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { slug } = request.params as { slug: string };
|
||||||
|
const plantType = await prisma.plantType.findUnique({
|
||||||
|
where: { slug }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plantType) {
|
||||||
|
return reply.status(404).send({ error: 'Plant type not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return plantType;
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to fetch plant type' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create plant type
|
||||||
|
fastify.post('/plant-types', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { name, strain, category, colour, growthDays, yieldGrams, notes, tags, customFields } = request.body as any;
|
||||||
|
|
||||||
|
// Generate slug from name
|
||||||
|
const baseSlug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
|
||||||
|
// Check for existing slug and make unique if needed
|
||||||
|
let slug = baseSlug;
|
||||||
|
let counter = 1;
|
||||||
|
while (await prisma.plantType.findUnique({ where: { slug } })) {
|
||||||
|
slug = `${baseSlug}-${counter}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plantType = await prisma.plantType.create({
|
||||||
|
data: {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
strain,
|
||||||
|
category,
|
||||||
|
colour,
|
||||||
|
growthDays,
|
||||||
|
yieldGrams,
|
||||||
|
notes,
|
||||||
|
tags: tags || [],
|
||||||
|
customFields
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(201).send(plantType);
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to create plant type' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update plant type
|
||||||
|
fastify.put('/plant-types/:slug', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { slug } = request.params as { slug: string };
|
||||||
|
const { name, strain, category, colour, growthDays, yieldGrams, notes, tags, customFields } = request.body as any;
|
||||||
|
|
||||||
|
const plantType = await prisma.plantType.update({
|
||||||
|
where: { slug },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
strain,
|
||||||
|
category,
|
||||||
|
colour,
|
||||||
|
growthDays,
|
||||||
|
yieldGrams,
|
||||||
|
notes,
|
||||||
|
tags,
|
||||||
|
customFields
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return plantType;
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to update plant type' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete plant type
|
||||||
|
fastify.delete('/plant-types/:slug', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { slug } = request.params as { slug: string };
|
||||||
|
await prisma.plantType.delete({ where: { slug } });
|
||||||
|
return reply.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to delete plant type' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,11 +161,12 @@ export async function metrcRoutes(fastify: FastifyInstance) {
|
||||||
try {
|
try {
|
||||||
const { propertyId, startDate, endDate } = request.query as any;
|
const { propertyId, startDate, endDate } = request.query as any;
|
||||||
|
|
||||||
// Get plant location history for the date range
|
// Get plant location history (MOVE events) for the date range
|
||||||
const prisma = fastify.prisma;
|
const prisma = fastify.prisma;
|
||||||
const history = await prisma.plantLocationHistory.findMany({
|
const history = await prisma.plantLifecycleEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
movedAt: {
|
type: 'MOVE',
|
||||||
|
createdAt: {
|
||||||
gte: startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
gte: startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||||
lte: endDate ? new Date(endDate) : new Date()
|
lte: endDate ? new Date(endDate) : new Date()
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +174,7 @@ export async function metrcRoutes(fastify: FastifyInstance) {
|
||||||
include: {
|
include: {
|
||||||
plant: true
|
plant: true
|
||||||
},
|
},
|
||||||
orderBy: { movedAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current plant locations
|
// Get current plant locations
|
||||||
|
|
@ -188,15 +189,15 @@ export async function metrcRoutes(fastify: FastifyInstance) {
|
||||||
summary: {
|
summary: {
|
||||||
totalPlants: currentReport.length,
|
totalPlants: currentReport.length,
|
||||||
totalMoves: history.length,
|
totalMoves: history.length,
|
||||||
uniquePlantsMoved: new Set(history.map(h => h.plantId)).size
|
uniquePlantsMoved: new Set(history.map((h: any) => h.plantId)).size
|
||||||
},
|
},
|
||||||
currentLocations: currentReport.slice(0, 100), // Limit for response size
|
currentLocations: currentReport.slice(0, 100), // Limit for response size
|
||||||
recentMoves: history.slice(0, 50).map(h => ({
|
recentMoves: history.slice(0, 50).map((h: any) => ({
|
||||||
plantTag: h.plant.tagNumber,
|
plantTag: h.plant.tagNumber,
|
||||||
from: h.fromAddress ? formatAddressForMetrc(h.fromAddress) : 'NEW',
|
from: h.fromAddress ? formatAddressForMetrc(h.fromAddress) : 'NEW',
|
||||||
to: formatAddressForMetrc(h.toAddress),
|
to: formatAddressForMetrc(h.toAddress),
|
||||||
movedAt: h.movedAt,
|
movedAt: h.createdAt,
|
||||||
reason: h.reason
|
reason: h.metadata?.reason || 'N/A'
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
60
backend/src/routes/plants.routes.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function plantRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.addHook('onRequest', async (request) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get History
|
||||||
|
fastify.get('/:id/history', async (request, reply) => {
|
||||||
|
const { id } = request.params as any;
|
||||||
|
try {
|
||||||
|
const events = await prisma.plantLifecycleEvent.findMany({
|
||||||
|
where: { plantId: id },
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: { id: true, name: true, email: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
return events;
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to fetch plant history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Event
|
||||||
|
fastify.post('/:id/events', async (request, reply) => {
|
||||||
|
const { id } = request.params as any;
|
||||||
|
const { type, metadata } = request.body as any;
|
||||||
|
const userId = (request.user as any)?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.status(401).send({ error: 'User not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await prisma.plantLifecycleEvent.create({
|
||||||
|
data: {
|
||||||
|
plantId: id,
|
||||||
|
type,
|
||||||
|
metadata: metadata || {},
|
||||||
|
createdById: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return event;
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to create lifecycle event' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
412
backend/src/routes/pulse.routes.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
/**
|
||||||
|
* Pulse Integration Routes
|
||||||
|
*
|
||||||
|
* Exposes Pulse sensor data through Veridian's API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getPulseService, initPulseService } from '../services/pulse.service';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function pulseRoutes(fastify: FastifyInstance) {
|
||||||
|
// Auth middleware
|
||||||
|
fastify.addHook('onRequest', async (request) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/status
|
||||||
|
* Check Pulse API connection status
|
||||||
|
*/
|
||||||
|
fastify.get('/status', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
error: 'Pulse API key not configured',
|
||||||
|
hint: 'Set PULSE_API_KEY environment variable'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pulse.testConnection();
|
||||||
|
return {
|
||||||
|
connected: result.success,
|
||||||
|
deviceCount: result.deviceCount,
|
||||||
|
error: result.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /pulse/configure
|
||||||
|
* Configure Pulse API key (admin only)
|
||||||
|
*/
|
||||||
|
fastify.post('/configure', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const { apiKey } = request.body as { apiKey: string };
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return reply.status(400).send({ error: 'API key required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the key before saving
|
||||||
|
const testService = initPulseService(apiKey);
|
||||||
|
const result = await testService.testConnection();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'Invalid API key',
|
||||||
|
details: result.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Store in database/env for persistence
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deviceCount: result.deviceCount,
|
||||||
|
message: 'Pulse API configured successfully'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/devices
|
||||||
|
* List all Pulse devices
|
||||||
|
*/
|
||||||
|
fastify.get('/devices', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await pulse.getDevices();
|
||||||
|
return { devices };
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/readings
|
||||||
|
* Get current readings from all devices
|
||||||
|
*/
|
||||||
|
fastify.get('/readings', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readings = await pulse.getCurrentReadings();
|
||||||
|
|
||||||
|
// Check thresholds and persist alerts (for demo/failsafe)
|
||||||
|
const alerts: any[] = [];
|
||||||
|
for (const reading of readings) {
|
||||||
|
const deviceIdentifier = reading.deviceName || reading.deviceId;
|
||||||
|
|
||||||
|
if (reading.temperature !== undefined && pulseThresholds.temperature.max && reading.temperature > pulseThresholds.temperature.max) {
|
||||||
|
const alertType = 'TEMPERATURE_HIGH';
|
||||||
|
|
||||||
|
// Check if active alert exists
|
||||||
|
const activeAlert = await prisma.environmentAlert.findFirst({
|
||||||
|
where: {
|
||||||
|
type: alertType,
|
||||||
|
message: { contains: deviceIdentifier },
|
||||||
|
resolvedAt: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeAlert) {
|
||||||
|
const newAlert = await prisma.environmentAlert.create({
|
||||||
|
data: {
|
||||||
|
type: alertType,
|
||||||
|
severity: 'WARNING',
|
||||||
|
message: `${deviceIdentifier}: ${alertType.replace('_', ' ')} (${reading.temperature.toFixed(1)}°F > ${pulseThresholds.temperature.max}°F)`,
|
||||||
|
value: reading.temperature,
|
||||||
|
threshold: pulseThresholds.temperature.max,
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
alerts.push(createAlert(reading, alertType, reading.temperature, pulseThresholds.temperature.max));
|
||||||
|
fastify.log.info(`🚨 Created new Pulse alert: ${newAlert.id}`);
|
||||||
|
}
|
||||||
|
} else if (reading.temperature !== undefined && pulseThresholds.temperature.max) {
|
||||||
|
// Temperature is within threshold - AUTO-RESOLVE any active alerts for this device
|
||||||
|
const resolvedAlerts = await prisma.environmentAlert.updateMany({
|
||||||
|
where: {
|
||||||
|
type: 'TEMPERATURE_HIGH',
|
||||||
|
message: { contains: deviceIdentifier },
|
||||||
|
resolvedAt: null
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
resolvedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resolvedAlerts.count > 0) {
|
||||||
|
fastify.log.info(`✅ Auto-resolved ${resolvedAlerts.count} alerts for ${deviceIdentifier} (temp now ${reading.temperature.toFixed(1)}°F)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast any new alerts
|
||||||
|
if (alerts.length > 0) {
|
||||||
|
alerts.forEach(a => broadcastAlert(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
readings,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/devices/:id/readings
|
||||||
|
* Get current reading for a specific device
|
||||||
|
*/
|
||||||
|
fastify.get('/devices/:id/readings', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reading = await pulse.getDeviceReading(id);
|
||||||
|
if (!reading) {
|
||||||
|
return reply.status(404).send({ error: 'Device not found' });
|
||||||
|
}
|
||||||
|
return { deviceId: id, ...reading };
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/sparklines
|
||||||
|
* Get sparkline data for all devices
|
||||||
|
*/
|
||||||
|
fastify.get('/sparklines', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sparklines = await pulse.getSparklines();
|
||||||
|
return { sparklines };
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/devices/:id/history
|
||||||
|
* Get historical readings for a device
|
||||||
|
*/
|
||||||
|
fastify.get('/devices/:id/history', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const { hours = 24 } = request.query as { hours?: number };
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readings = await pulse.getHistory(id, hours);
|
||||||
|
return {
|
||||||
|
deviceId: id,
|
||||||
|
hours,
|
||||||
|
count: readings.length,
|
||||||
|
readings
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/thresholds
|
||||||
|
* Get current threshold configuration
|
||||||
|
*/
|
||||||
|
fastify.get('/thresholds', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
return {
|
||||||
|
thresholds: pulseThresholds,
|
||||||
|
lastUpdated: thresholdsLastUpdated?.toISOString() || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /pulse/thresholds
|
||||||
|
* Set threshold alerts for Pulse sensors
|
||||||
|
*/
|
||||||
|
fastify.post('/thresholds', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const config = request.body as {
|
||||||
|
temperature?: { min: number; max: number };
|
||||||
|
humidity?: { min: number; max: number };
|
||||||
|
vpd?: { min: number; max: number };
|
||||||
|
co2?: { min: number; max: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.temperature) {
|
||||||
|
pulseThresholds.temperature = config.temperature;
|
||||||
|
}
|
||||||
|
if (config.humidity) {
|
||||||
|
pulseThresholds.humidity = config.humidity;
|
||||||
|
}
|
||||||
|
if (config.vpd) {
|
||||||
|
pulseThresholds.vpd = config.vpd;
|
||||||
|
}
|
||||||
|
if (config.co2) {
|
||||||
|
pulseThresholds.co2 = config.co2;
|
||||||
|
}
|
||||||
|
|
||||||
|
thresholdsLastUpdated = new Date();
|
||||||
|
|
||||||
|
fastify.log.info({ event: 'pulse_thresholds_updated', thresholds: pulseThresholds });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
thresholds: pulseThresholds,
|
||||||
|
message: 'Thresholds updated successfully'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /pulse/check
|
||||||
|
* Check current readings against thresholds and broadcast alerts
|
||||||
|
*/
|
||||||
|
fastify.post('/check', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readings = await pulse.getCurrentReadings();
|
||||||
|
const alerts: any[] = [];
|
||||||
|
|
||||||
|
for (const reading of readings) {
|
||||||
|
// Temperature check
|
||||||
|
if (reading.temperature !== undefined) {
|
||||||
|
if (pulseThresholds.temperature.max && reading.temperature > pulseThresholds.temperature.max) {
|
||||||
|
alerts.push(createAlert(reading, 'TEMPERATURE_HIGH', reading.temperature, pulseThresholds.temperature.max));
|
||||||
|
}
|
||||||
|
if (pulseThresholds.temperature.min && reading.temperature < pulseThresholds.temperature.min) {
|
||||||
|
alerts.push(createAlert(reading, 'TEMPERATURE_LOW', reading.temperature, pulseThresholds.temperature.min));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Humidity check
|
||||||
|
if (reading.humidity !== undefined) {
|
||||||
|
if (pulseThresholds.humidity.max && reading.humidity > pulseThresholds.humidity.max) {
|
||||||
|
alerts.push(createAlert(reading, 'HUMIDITY_HIGH', reading.humidity, pulseThresholds.humidity.max));
|
||||||
|
}
|
||||||
|
if (pulseThresholds.humidity.min && reading.humidity < pulseThresholds.humidity.min) {
|
||||||
|
alerts.push(createAlert(reading, 'HUMIDITY_LOW', reading.humidity, pulseThresholds.humidity.min));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPD check
|
||||||
|
if (reading.vpd !== undefined) {
|
||||||
|
if (pulseThresholds.vpd.max && reading.vpd > pulseThresholds.vpd.max) {
|
||||||
|
alerts.push(createAlert(reading, 'VPD_HIGH', reading.vpd, pulseThresholds.vpd.max));
|
||||||
|
}
|
||||||
|
if (pulseThresholds.vpd.min && reading.vpd < pulseThresholds.vpd.min) {
|
||||||
|
alerts.push(createAlert(reading, 'VPD_LOW', reading.vpd, pulseThresholds.vpd.min));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CO2 check
|
||||||
|
if (reading.co2 !== undefined) {
|
||||||
|
if (pulseThresholds.co2.max && reading.co2 > pulseThresholds.co2.max) {
|
||||||
|
alerts.push(createAlert(reading, 'CO2_HIGH', reading.co2, pulseThresholds.co2.max));
|
||||||
|
}
|
||||||
|
if (pulseThresholds.co2.min && reading.co2 < pulseThresholds.co2.min) {
|
||||||
|
alerts.push(createAlert(reading, 'CO2_LOW', reading.co2, pulseThresholds.co2.min));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast alerts via WebSocket
|
||||||
|
for (const alert of alerts) {
|
||||||
|
broadcastAlert(alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
devicesChecked: readings.length,
|
||||||
|
alertsTriggered: alerts.length,
|
||||||
|
alerts,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory threshold storage (would be persisted to DB in production)
|
||||||
|
const pulseThresholds = {
|
||||||
|
temperature: { min: 65, max: 82 }, // °F
|
||||||
|
humidity: { min: 40, max: 70 }, // %
|
||||||
|
vpd: { min: 0.8, max: 1.2 }, // kPa
|
||||||
|
co2: { min: 400, max: 1500 } // ppm
|
||||||
|
};
|
||||||
|
|
||||||
|
let thresholdsLastUpdated: Date | null = null;
|
||||||
|
|
||||||
|
// Import broadcast function
|
||||||
|
import { broadcastAlert } from '../plugins/websocket';
|
||||||
|
|
||||||
|
function createAlert(reading: any, type: string, value: number, threshold: number) {
|
||||||
|
return {
|
||||||
|
id: `pulse-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
type,
|
||||||
|
sensorName: reading.deviceName || `Device ${reading.deviceId}`,
|
||||||
|
value,
|
||||||
|
threshold,
|
||||||
|
message: `${reading.deviceName || 'Pulse Sensor'}: ${type.replace('_', ' ')} (${value.toFixed(1)} vs threshold ${threshold})`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
112
backend/src/scripts/demo-alerts.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Demo Alert Generator
|
||||||
|
*
|
||||||
|
* Generates fake sensor alerts for demonstration purposes.
|
||||||
|
* Run with: npx ts-node src/scripts/demo-alerts.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO_SENSORS = [
|
||||||
|
{ id: 'demo-pulse-1', name: 'Flower Room - Pulse Pro', roomId: 'flower-room' },
|
||||||
|
{ id: 'demo-pulse-2', name: 'Veg Room - Pulse', roomId: 'veg-room' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALERT_TYPES = [
|
||||||
|
{ type: 'TEMPERATURE_HIGH', metric: 'temperature', min: 82, max: 88 },
|
||||||
|
{ type: 'TEMPERATURE_LOW', metric: 'temperature', min: 58, max: 64 },
|
||||||
|
{ type: 'HUMIDITY_HIGH', metric: 'humidity', min: 72, max: 78 },
|
||||||
|
{ type: 'HUMIDITY_LOW', metric: 'humidity', min: 35, max: 42 },
|
||||||
|
{ type: 'VPD_HIGH', metric: 'vpd', min: 1.5, max: 1.8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function generateDemoAlert() {
|
||||||
|
const sensor = DEMO_SENSORS[Math.floor(Math.random() * DEMO_SENSORS.length)];
|
||||||
|
const alertConfig = ALERT_TYPES[Math.floor(Math.random() * ALERT_TYPES.length)];
|
||||||
|
|
||||||
|
const value = alertConfig.min + Math.random() * (alertConfig.max - alertConfig.min);
|
||||||
|
const threshold = alertConfig.type.includes('HIGH')
|
||||||
|
? value - 5
|
||||||
|
: value + 5;
|
||||||
|
|
||||||
|
const alert = await prisma.environmentAlert.create({
|
||||||
|
data: {
|
||||||
|
type: alertConfig.type,
|
||||||
|
severity: 'WARNING',
|
||||||
|
message: `${sensor.name}: ${alertConfig.metric} ${alertConfig.type.includes('HIGH') ? 'above' : 'below'} threshold (${value.toFixed(1)} vs ${threshold.toFixed(1)})`,
|
||||||
|
value: value,
|
||||||
|
threshold: threshold
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🚨 Generated alert: ${alert.message}`);
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateDemoReading() {
|
||||||
|
const sensor = DEMO_SENSORS[Math.floor(Math.random() * DEMO_SENSORS.length)];
|
||||||
|
|
||||||
|
// Generate realistic readings
|
||||||
|
const temperature = 72 + (Math.random() - 0.5) * 10; // 67-77°F
|
||||||
|
const humidity = 55 + (Math.random() - 0.5) * 15; // 47-62%
|
||||||
|
const vpd = 1.0 + (Math.random() - 0.5) * 0.4; // 0.8-1.2 kPa
|
||||||
|
|
||||||
|
const reading = await prisma.sensorReading.create({
|
||||||
|
data: {
|
||||||
|
sensorId: sensor.id,
|
||||||
|
value: temperature,
|
||||||
|
unit: '°F',
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📊 Generated reading: ${sensor.name} - ${temperature.toFixed(1)}°F, ${humidity.toFixed(1)}% RH`);
|
||||||
|
return reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDemoMode(intervalMs: number = 5000, alertChance: number = 0.2) {
|
||||||
|
console.log('🎭 Demo mode started');
|
||||||
|
console.log(` Interval: ${intervalMs}ms`);
|
||||||
|
console.log(` Alert chance: ${alertChance * 100}%`);
|
||||||
|
console.log(' Press Ctrl+C to stop\n');
|
||||||
|
|
||||||
|
const loop = async () => {
|
||||||
|
await generateDemoReading();
|
||||||
|
|
||||||
|
if (Math.random() < alertChance) {
|
||||||
|
await generateDemoAlert();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial run
|
||||||
|
await loop();
|
||||||
|
|
||||||
|
// Repeat
|
||||||
|
setInterval(loop, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI interface
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0] || 'run';
|
||||||
|
|
||||||
|
if (command === 'alert') {
|
||||||
|
generateDemoAlert().then(() => process.exit(0));
|
||||||
|
} else if (command === 'reading') {
|
||||||
|
generateDemoReading().then(() => process.exit(0));
|
||||||
|
} else if (command === 'run') {
|
||||||
|
const interval = parseInt(args[1] || '5000');
|
||||||
|
const alertChance = parseFloat(args[2] || '0.2');
|
||||||
|
runDemoMode(interval, alertChance);
|
||||||
|
} else {
|
||||||
|
console.log(`
|
||||||
|
Usage:
|
||||||
|
npx ts-node src/scripts/demo-alerts.ts alert - Generate single alert
|
||||||
|
npx ts-node src/scripts/demo-alerts.ts reading - Generate single reading
|
||||||
|
npx ts-node src/scripts/demo-alerts.ts run [interval] [alertChance]
|
||||||
|
- Run continuous demo mode
|
||||||
|
- interval: ms between readings (default: 5000)
|
||||||
|
- alertChance: 0-1 probability (default: 0.2)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import fastify from 'fastify';
|
import fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
import jwt from '@fastify/jwt';
|
import jwt from '@fastify/jwt';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { prismaPlugin } from './plugins/prisma';
|
import { prismaPlugin } from './plugins/prisma';
|
||||||
|
|
@ -23,6 +24,8 @@ import { metrcRoutes } from './routes/metrc.routes';
|
||||||
import { visitorRoutes } from './routes/visitors.routes';
|
import { visitorRoutes } from './routes/visitors.routes';
|
||||||
import { accessZoneRoutes } from './routes/access-zones.routes';
|
import { accessZoneRoutes } from './routes/access-zones.routes';
|
||||||
import { messagingRoutes } from './routes/messaging.routes';
|
import { messagingRoutes } from './routes/messaging.routes';
|
||||||
|
import { pulseRoutes } from './routes/pulse.routes';
|
||||||
|
import { websocketPlugin } from './plugins/websocket';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
@ -31,6 +34,20 @@ const server = fastify({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register Plugins
|
// Register Plugins
|
||||||
|
server.register(cors, {
|
||||||
|
origin: true, // Allow all origins to resolve specific Capacitor/WebView mismatches
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual OPTIONS handler as fallback
|
||||||
|
server.options('/*', async (request, reply) => {
|
||||||
|
reply.header('Access-Control-Allow-Origin', request.headers.origin || '*');
|
||||||
|
reply.header('Access-Control-Allow-Credentials', 'true');
|
||||||
|
reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||||
|
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
server.register(prismaPlugin);
|
server.register(prismaPlugin);
|
||||||
server.register(jwt, {
|
server.register(jwt, {
|
||||||
secret: process.env.JWT_SECRET || 'supersecret'
|
secret: process.env.JWT_SECRET || 'supersecret'
|
||||||
|
|
@ -58,6 +75,8 @@ server.register(metrcRoutes, { prefix: '/api/metrc' });
|
||||||
server.register(visitorRoutes, { prefix: '/api/visitors' });
|
server.register(visitorRoutes, { prefix: '/api/visitors' });
|
||||||
server.register(accessZoneRoutes, { prefix: '/api/zones' });
|
server.register(accessZoneRoutes, { prefix: '/api/zones' });
|
||||||
server.register(messagingRoutes, { prefix: '/api/messaging' });
|
server.register(messagingRoutes, { prefix: '/api/messaging' });
|
||||||
|
import { plantRoutes } from './routes/plants.routes';
|
||||||
|
server.register(plantRoutes, { prefix: '/api/plants' });
|
||||||
|
|
||||||
// Phase 10: Compliance
|
// Phase 10: Compliance
|
||||||
import { auditRoutes } from './routes/audit.routes';
|
import { auditRoutes } from './routes/audit.routes';
|
||||||
|
|
@ -75,6 +94,16 @@ server.register(financialRoutes, { prefix: '/api/financial' });
|
||||||
server.register(insightsRoutes, { prefix: '/api/insights' });
|
server.register(insightsRoutes, { prefix: '/api/insights' });
|
||||||
server.register(uploadRoutes, { prefix: '/api/upload' });
|
server.register(uploadRoutes, { prefix: '/api/upload' });
|
||||||
|
|
||||||
|
// Pulse sensor integration
|
||||||
|
server.register(pulseRoutes, { prefix: '/api/pulse' });
|
||||||
|
|
||||||
|
// Camera/Security monitoring
|
||||||
|
import { cameraRoutes } from './routes/cameras.routes';
|
||||||
|
server.register(cameraRoutes, { prefix: '/api/cameras' });
|
||||||
|
|
||||||
|
// WebSocket for real-time alerts
|
||||||
|
server.register(websocketPlugin);
|
||||||
|
|
||||||
// Admin routes (demo/testing)
|
// Admin routes (demo/testing)
|
||||||
import { adminRoutes } from './routes/admin.routes';
|
import { adminRoutes } from './routes/admin.routes';
|
||||||
server.register(adminRoutes, { prefix: '/api/admin' });
|
server.register(adminRoutes, { prefix: '/api/admin' });
|
||||||
|
|
|
||||||
238
backend/src/services/pulse.service.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
/**
|
||||||
|
* Pulse Grow API Service
|
||||||
|
*
|
||||||
|
* Server-side integration with Pulse Grow sensor platform.
|
||||||
|
* API Docs: https://api.pulsegrow.com/docs
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PULSE_API_BASE = 'https://api.pulsegrow.com';
|
||||||
|
|
||||||
|
export interface PulseDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isOnline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PulseReading {
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
temperature: number; // Fahrenheit
|
||||||
|
humidity: number; // %
|
||||||
|
vpd: number; // kPa
|
||||||
|
dewpoint: number; // Fahrenheit
|
||||||
|
light?: number; // lux
|
||||||
|
co2?: number; // ppm
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PulseService {
|
||||||
|
private apiKey: string;
|
||||||
|
|
||||||
|
constructor(apiKey: string) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(path: string): Promise<any> {
|
||||||
|
const res = await fetch(`${PULSE_API_BASE}${path}`, {
|
||||||
|
headers: {
|
||||||
|
'x-api-key': this.apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Pulse API error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all devices with their latest readings
|
||||||
|
* Uses /all-devices which returns deviceViewDtos with mostRecentDataPoint
|
||||||
|
*/
|
||||||
|
async getAllDevicesWithReadings(): Promise<{ devices: PulseDevice[]; readings: PulseReading[] }> {
|
||||||
|
const data = await this.fetch('/all-devices');
|
||||||
|
|
||||||
|
const devices: PulseDevice[] = [];
|
||||||
|
const readings: PulseReading[] = [];
|
||||||
|
|
||||||
|
// Process device view DTOs
|
||||||
|
for (const d of (data.deviceViewDtos || [])) {
|
||||||
|
const deviceId = String(d.id || d.deviceId);
|
||||||
|
const deviceName = d.name || 'Unknown';
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: deviceId,
|
||||||
|
name: deviceName,
|
||||||
|
type: this.getDeviceType(d.deviceType),
|
||||||
|
isOnline: d.mostRecentDataPoint?.pluggedIn ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (d.mostRecentDataPoint) {
|
||||||
|
const dp = d.mostRecentDataPoint;
|
||||||
|
readings.push({
|
||||||
|
deviceId,
|
||||||
|
deviceName,
|
||||||
|
temperature: dp.temperatureF ?? 0,
|
||||||
|
humidity: dp.humidityRh ?? 0,
|
||||||
|
vpd: dp.vpd ?? 0,
|
||||||
|
dewpoint: dp.dpF ?? this.calculateDewpoint(dp.temperatureF, dp.humidityRh),
|
||||||
|
light: dp.lightLux || undefined,
|
||||||
|
co2: dp.co2 || undefined,
|
||||||
|
timestamp: new Date(dp.createdAt || Date.now()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process universal sensor views if present
|
||||||
|
for (const s of (data.universalSensorViews || [])) {
|
||||||
|
const deviceId = String(s.id || s.deviceId);
|
||||||
|
const deviceName = s.name || 'Unknown Sensor';
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: deviceId,
|
||||||
|
name: deviceName,
|
||||||
|
type: 'universal',
|
||||||
|
isOnline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { devices, readings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all devices for this grow
|
||||||
|
*/
|
||||||
|
async getDevices(): Promise<PulseDevice[]> {
|
||||||
|
const { devices } = await this.getAllDevicesWithReadings();
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current readings for all devices
|
||||||
|
*/
|
||||||
|
async getCurrentReadings(): Promise<PulseReading[]> {
|
||||||
|
const { readings } = await this.getAllDevicesWithReadings();
|
||||||
|
return readings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current reading for a specific device
|
||||||
|
*/
|
||||||
|
async getDeviceReading(deviceId: string): Promise<Omit<PulseReading, 'deviceId' | 'deviceName'> | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.fetch(`/devices/${deviceId}/recent-data`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
temperature: data.temperatureF ?? 0,
|
||||||
|
humidity: data.humidityRh ?? 0,
|
||||||
|
vpd: data.vpd ?? 0,
|
||||||
|
dewpoint: data.dpF ?? this.calculateDewpoint(data.temperatureF, data.humidityRh),
|
||||||
|
light: data.lightLux || undefined,
|
||||||
|
co2: data.co2 || undefined,
|
||||||
|
timestamp: new Date(data.createdAt || Date.now()),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical data for a device
|
||||||
|
*/
|
||||||
|
async getHistory(deviceId: string, hours: number = 24): Promise<PulseReading[]> {
|
||||||
|
const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||||
|
const data = await this.fetch(`/devices/${deviceId}/data-range?start=${start}`);
|
||||||
|
|
||||||
|
return (data || []).map((r: any) => ({
|
||||||
|
deviceId,
|
||||||
|
deviceName: '',
|
||||||
|
temperature: r.temperatureF ?? 0,
|
||||||
|
humidity: r.humidityRh ?? 0,
|
||||||
|
vpd: r.vpd ?? 0,
|
||||||
|
dewpoint: r.dpF ?? this.calculateDewpoint(r.temperatureF, r.humidityRh),
|
||||||
|
light: r.lightLux || undefined,
|
||||||
|
co2: r.co2 || undefined,
|
||||||
|
timestamp: new Date(r.createdAt),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sparkline data (last 1 hour) for all devices
|
||||||
|
*/
|
||||||
|
async getSparklines(): Promise<Record<string, PulseReading[]>> {
|
||||||
|
const devices = await this.getDevices();
|
||||||
|
const results: Record<string, PulseReading[]> = {};
|
||||||
|
|
||||||
|
await Promise.all(devices.map(async (device) => {
|
||||||
|
try {
|
||||||
|
// Get 24 hours of history
|
||||||
|
const history = await this.getHistory(device.id, 24);
|
||||||
|
// Sort by timestamp ascending
|
||||||
|
results[device.id] = history.sort((a, b) =>
|
||||||
|
a.timestamp.getTime() - b.timestamp.getTime()
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to fetch history for sparkline (device ${device.id})`, error);
|
||||||
|
results[device.id] = [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Pulse API
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<{ success: boolean; deviceCount: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const { devices } = await this.getAllDevicesWithReadings();
|
||||||
|
return { success: true, deviceCount: devices.length };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, deviceCount: 0, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map device type number to string
|
||||||
|
*/
|
||||||
|
private getDeviceType(type: number): string {
|
||||||
|
const types: Record<number, string> = {
|
||||||
|
0: 'Pulse One',
|
||||||
|
1: 'Pulse Pro',
|
||||||
|
2: 'Pulse Hub',
|
||||||
|
};
|
||||||
|
return types[type] || 'Pulse';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate dewpoint from temperature and humidity
|
||||||
|
*/
|
||||||
|
private calculateDewpoint(tempF: number, humidity: number): number {
|
||||||
|
if (!tempF || !humidity) return 0;
|
||||||
|
const tempC = (tempF - 32) * 5 / 9;
|
||||||
|
const a = 17.27;
|
||||||
|
const b = 237.7;
|
||||||
|
const alpha = ((a * tempC) / (b + tempC)) + Math.log(humidity / 100);
|
||||||
|
const dewpointC = (b * alpha) / (a - alpha);
|
||||||
|
return (dewpointC * 9 / 5) + 32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance (initialized with API key from env)
|
||||||
|
let pulseServiceInstance: PulseService | null = null;
|
||||||
|
|
||||||
|
export function getPulseService(): PulseService | null {
|
||||||
|
if (!pulseServiceInstance && process.env.PULSE_API_KEY) {
|
||||||
|
pulseServiceInstance = new PulseService(process.env.PULSE_API_KEY);
|
||||||
|
}
|
||||||
|
return pulseServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initPulseService(apiKey: string): PulseService {
|
||||||
|
pulseServiceInstance = new PulseService(apiKey);
|
||||||
|
return pulseServiceInstance;
|
||||||
|
}
|
||||||
266
backend/src/types/layout-schemas.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* Layout Zod Validation Schemas
|
||||||
|
* Inspired by Rackula's schema architecture
|
||||||
|
* Schema v1.0.0 - Flat structure with plant-specific fields
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants & Patterns
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug pattern: lowercase alphanumeric with hyphens, no leading/trailing/consecutive hyphens
|
||||||
|
*/
|
||||||
|
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hex colour pattern: 6-character hex with # prefix
|
||||||
|
*/
|
||||||
|
const HEX_COLOUR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Enums
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plant category types for library organization
|
||||||
|
*/
|
||||||
|
export const PlantCategorySchema = z.enum([
|
||||||
|
'VEG',
|
||||||
|
'FLOWER',
|
||||||
|
'MOTHER',
|
||||||
|
'CLONE',
|
||||||
|
'SEEDLING',
|
||||||
|
]);
|
||||||
|
export type PlantCategory = z.infer<typeof PlantCategorySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Section subtype - preserves visual diversity while using unified Rack concept
|
||||||
|
*/
|
||||||
|
export const SectionSubtypeSchema = z.enum([
|
||||||
|
'TABLE',
|
||||||
|
'RACK',
|
||||||
|
'TRAY',
|
||||||
|
'HANGER',
|
||||||
|
'FLOOR',
|
||||||
|
]);
|
||||||
|
export type SectionSubtype = z.infer<typeof SectionSubtypeSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position status
|
||||||
|
*/
|
||||||
|
export const PositionStatusSchema = z.enum([
|
||||||
|
'EMPTY',
|
||||||
|
'OCCUPIED',
|
||||||
|
'RESERVED',
|
||||||
|
'DAMAGED',
|
||||||
|
]);
|
||||||
|
export type PositionStatus = z.infer<typeof PositionStatusSchema>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Core Schemas
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug schema for plant type identification
|
||||||
|
*/
|
||||||
|
export const SlugSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Slug is required')
|
||||||
|
.max(100, 'Slug must be 100 characters or less')
|
||||||
|
.regex(
|
||||||
|
SLUG_PATTERN,
|
||||||
|
'Slug must be lowercase with hyphens only (no leading/trailing/consecutive)'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plant Type - template definition in library
|
||||||
|
* Analogous to Rackula's DeviceType
|
||||||
|
*/
|
||||||
|
export const PlantTypeSchema = z.object({
|
||||||
|
// --- Core Identity ---
|
||||||
|
slug: SlugSchema,
|
||||||
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
|
strain: z.string().max(100).optional(),
|
||||||
|
|
||||||
|
// --- Classification ---
|
||||||
|
category: PlantCategorySchema,
|
||||||
|
|
||||||
|
// --- Visual Properties ---
|
||||||
|
colour: z
|
||||||
|
.string()
|
||||||
|
.regex(HEX_COLOUR_PATTERN, 'Color must be valid hex (e.g., #4A90D9)'),
|
||||||
|
|
||||||
|
// --- Growth Properties ---
|
||||||
|
growthDays: z.number().int().positive().optional(),
|
||||||
|
yieldGrams: z.number().positive().optional(),
|
||||||
|
|
||||||
|
// --- Metadata ---
|
||||||
|
notes: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
export type PlantType = z.infer<typeof PlantTypeSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placed Plant - instance at a specific position
|
||||||
|
* Analogous to Rackula's PlacedDevice
|
||||||
|
*/
|
||||||
|
export const PlacedPlantSchema = z.object({
|
||||||
|
// --- Identity ---
|
||||||
|
id: z.string().uuid(),
|
||||||
|
plantTypeSlug: z.string().min(1, 'Plant type slug is required'),
|
||||||
|
|
||||||
|
// --- Position ---
|
||||||
|
row: z.number().int().min(0),
|
||||||
|
column: z.number().int().min(0),
|
||||||
|
tier: z.number().int().min(1).default(1),
|
||||||
|
slot: z.number().int().min(1).default(1),
|
||||||
|
|
||||||
|
// --- Overrides ---
|
||||||
|
name: z.string().optional(),
|
||||||
|
colourOverride: z.string().regex(HEX_COLOUR_PATTERN).optional(),
|
||||||
|
|
||||||
|
// --- Metadata ---
|
||||||
|
notes: z.string().optional(),
|
||||||
|
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
export type PlacedPlant = z.infer<typeof PlacedPlantSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rack/Section - container structure for plants
|
||||||
|
* Analogous to Rackula's Rack, with subtype for visual variety
|
||||||
|
*/
|
||||||
|
export const RackSchema = z.object({
|
||||||
|
// --- Identity ---
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
code: z.string().max(20).optional(),
|
||||||
|
|
||||||
|
// --- Type (hybrid approach) ---
|
||||||
|
subtype: SectionSubtypeSchema.default('RACK'),
|
||||||
|
|
||||||
|
// --- Dimensions ---
|
||||||
|
rows: z.number().int().min(1).max(100),
|
||||||
|
columns: z.number().int().min(1).max(100),
|
||||||
|
tiers: z.number().int().min(1).max(10).default(1),
|
||||||
|
spacing: z.number().positive().optional(), // inches between positions
|
||||||
|
|
||||||
|
// --- Position on floor ---
|
||||||
|
posX: z.number(),
|
||||||
|
posY: z.number(),
|
||||||
|
width: z.number().positive(),
|
||||||
|
height: z.number().positive(),
|
||||||
|
|
||||||
|
// --- Contents ---
|
||||||
|
plants: z.array(PlacedPlantSchema).default([]),
|
||||||
|
|
||||||
|
// --- Metadata ---
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type Rack = z.infer<typeof RackSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room Layout - complete room state
|
||||||
|
*/
|
||||||
|
export const RoomLayoutSchema = z.object({
|
||||||
|
// --- Identity ---
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
code: z.string().max(20).optional(),
|
||||||
|
type: z.enum(['VEG', 'FLOWER', 'DRY', 'CURE', 'MOTHER', 'CLONE', 'FACILITY']),
|
||||||
|
|
||||||
|
// --- Position on floor ---
|
||||||
|
posX: z.number(),
|
||||||
|
posY: z.number(),
|
||||||
|
width: z.number().positive(),
|
||||||
|
height: z.number().positive(),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
|
||||||
|
// --- Visual ---
|
||||||
|
color: z.string().regex(HEX_COLOUR_PATTERN).optional(),
|
||||||
|
|
||||||
|
// --- Contents ---
|
||||||
|
racks: z.array(RackSchema).default([]),
|
||||||
|
});
|
||||||
|
export type RoomLayout = z.infer<typeof RoomLayoutSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor Layout - complete floor state for serialization
|
||||||
|
*/
|
||||||
|
export const FloorLayoutSchema = z.object({
|
||||||
|
// --- Metadata ---
|
||||||
|
version: z.string().default('1.0.0'),
|
||||||
|
name: z.string(),
|
||||||
|
|
||||||
|
// --- Floor dimensions ---
|
||||||
|
width: z.number().positive(),
|
||||||
|
height: z.number().positive(),
|
||||||
|
ceilingHeight: z.number().positive().optional(),
|
||||||
|
|
||||||
|
// --- Contents ---
|
||||||
|
rooms: z.array(RoomLayoutSchema),
|
||||||
|
|
||||||
|
// --- Plant Type Library ---
|
||||||
|
plantTypes: z.array(PlantTypeSchema).default([]),
|
||||||
|
});
|
||||||
|
export type FloorLayout = z.infer<typeof FloorLayoutSchema>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Types for Creation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper type for creating a PlantType
|
||||||
|
*/
|
||||||
|
export const CreatePlantTypeSchema = PlantTypeSchema.omit({
|
||||||
|
slug: true,
|
||||||
|
}).extend({
|
||||||
|
name: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type CreatePlantTypeData = z.infer<typeof CreatePlantTypeSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper type for updating a PlantType
|
||||||
|
*/
|
||||||
|
export const UpdatePlantTypeSchema = PlantTypeSchema.partial().omit({
|
||||||
|
slug: true,
|
||||||
|
});
|
||||||
|
export type UpdatePlantTypeData = z.infer<typeof UpdatePlantTypeSchema>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a slug from a name
|
||||||
|
*/
|
||||||
|
export function generateSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that all slugs in an array are unique
|
||||||
|
*/
|
||||||
|
export function validateSlugUniqueness(items: { slug: string }[]): string[] {
|
||||||
|
const slugCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
slugCounts.set(item.slug, (slugCounts.get(item.slug) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates: string[] = [];
|
||||||
|
for (const [slug, count] of slugCounts) {
|
||||||
|
if (count > 1) {
|
||||||
|
duplicates.push(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicates;
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@ services:
|
||||||
JWT_REFRESH_EXPIRY: 7d
|
JWT_REFRESH_EXPIRY: 7d
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
PULSE_API_KEY: ${PULSE_API_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -72,6 +73,25 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./go2rtc.yaml:/config/go2rtc.yaml
|
- ./go2rtc.yaml:/config/go2rtc.yaml
|
||||||
|
|
||||||
|
# Arlo camera bridge - converts Arlo Cloud streams to RTSP
|
||||||
|
arlo-streamer:
|
||||||
|
image: kaffetorsk/arlo-streamer:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
environment:
|
||||||
|
- ARLO_USER=${ARLO_USER}
|
||||||
|
- ARLO_PASS=${ARLO_PASS}
|
||||||
|
- IMAP_HOST=${ARLO_IMAP_HOST}
|
||||||
|
- IMAP_USER=${ARLO_IMAP_USER}
|
||||||
|
- IMAP_PASS=${ARLO_IMAP_PASS}
|
||||||
|
# Output to go2rtc's RTSP server - {name} is replaced by camera name
|
||||||
|
- FFMPEG_OUT=-c:v copy -c:a copy -f rtsp rtsp://go2rtc:8554/{name}
|
||||||
|
- MOTION_TIMEOUT=60
|
||||||
|
- PYAARLO_RECONNECT_EVERY=110
|
||||||
|
depends_on:
|
||||||
|
- go2rtc
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|
|
||||||
101
frontend/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
2
frontend/android/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
58
frontend/android/app/build.gradle
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "run.runfoo.veridian"
|
||||||
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "run.runfoo.veridian"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
22
frontend/android/app/capacitor.build.gradle
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':capacitor-camera')
|
||||||
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation project(':capacitor-splash-screen')
|
||||||
|
implementation project(':capacitor-status-bar')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
21
frontend/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package run.runfoo.veridian;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
BIN
frontend/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
frontend/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,34 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
BIN
frontend/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
frontend/android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 397 KiB |
BIN
frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 397 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 397 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 397 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
7
frontend/android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Veridian</string>
|
||||||
|
<string name="title_activity_main">Veridian</string>
|
||||||
|
<string name="package_name">run.runfoo.veridian</string>
|
||||||
|
<string name="custom_url_scheme">run.runfoo.veridian</string>
|
||||||
|
</resources>
|
||||||
22
frontend/android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
frontend/android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system"/>
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="false">
|
||||||
|
<domain includeSubdomains="true">veridian.runfoo.run</domain>
|
||||||
|
<domain includeSubdomains="true">runfoo.run</domain>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system"/>
|
||||||
|
</trust-anchors>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/android/build.gradle
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
tasks.withType(JavaCompile) {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
15
frontend/android/capacitor.settings.gradle
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-camera'
|
||||||
|
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
|
||||||
|
|
||||||
|
include ':capacitor-push-notifications'
|
||||||
|
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
|
include ':capacitor-splash-screen'
|
||||||
|
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
|
||||||
|
|
||||||
|
include ':capacitor-status-bar'
|
||||||
|
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||||
22
frontend/android/gradle.properties
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
BIN
frontend/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
frontend/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
frontend/android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
frontend/android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
5
frontend/android/settings.gradle
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
16
frontend/android/variables.gradle
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
ext {
|
||||||
|
minSdkVersion = 24
|
||||||
|
compileSdkVersion = 36
|
||||||
|
targetSdkVersion = 36
|
||||||
|
androidxActivityVersion = '1.11.0'
|
||||||
|
androidxAppCompatVersion = '1.7.1'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||||
|
androidxCoreVersion = '1.17.0'
|
||||||
|
androidxFragmentVersion = '1.8.9'
|
||||||
|
coreSplashScreenVersion = '1.2.0'
|
||||||
|
androidxWebkitVersion = '1.14.0'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.3.0'
|
||||||
|
androidxEspressoCoreVersion = '3.7.0'
|
||||||
|
cordovaAndroidVersion = '14.0.1'
|
||||||
|
}
|
||||||
35
frontend/capacitor.config.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'run.runfoo.veridian',
|
||||||
|
appName: 'Veridian',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
// For development, you can use localhost
|
||||||
|
// For production APK, the app will use the built-in web assets
|
||||||
|
androidScheme: 'https',
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
SplashScreen: {
|
||||||
|
launchShowDuration: 2000,
|
||||||
|
backgroundColor: '#09090b',
|
||||||
|
showSpinner: false,
|
||||||
|
androidSplashResourceName: 'splash',
|
||||||
|
androidScaleType: 'CENTER_CROP',
|
||||||
|
},
|
||||||
|
StatusBar: {
|
||||||
|
backgroundColor: '#09090b',
|
||||||
|
style: 'DARK',
|
||||||
|
overlaysWebView: false,
|
||||||
|
},
|
||||||
|
PushNotifications: {
|
||||||
|
presentationOptions: ['badge', 'sound', 'alert'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
allowMixedContent: false,
|
||||||
|
backgroundColor: '#09090b',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -7,10 +7,10 @@
|
||||||
<meta name="theme-color" content="#10b981" />
|
<meta name="theme-color" content="#10b981" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Visitor Kiosk" />
|
<meta name="apple-mobile-web-app-title" content="Veridian" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
<title>Veridian - Visitor Kiosk</title>
|
<title>Veridian - Cultivation Platform</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
1572
frontend/package-lock.json
generated
|
|
@ -5,12 +5,19 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^8.0.0",
|
||||||
|
"@capacitor/camera": "^8.0.0",
|
||||||
|
"@capacitor/cli": "^8.0.0",
|
||||||
|
"@capacitor/core": "^8.0.0",
|
||||||
|
"@capacitor/push-notifications": "^8.0.0",
|
||||||
|
"@capacitor/splash-screen": "^8.0.0",
|
||||||
|
"@capacitor/status-bar": "^8.0.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|
@ -22,10 +29,13 @@
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^25.7.2",
|
"i18next": "^25.7.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"immer": "^11.0.1",
|
"immer": "^11.0.1",
|
||||||
|
"jspdf": "^4.0.0",
|
||||||
"konva": "^9.3.6",
|
"konva": "^9.3.6",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|
@ -35,8 +45,11 @@
|
||||||
"react-i18next": "^16.4.1",
|
"react-i18next": "^16.4.1",
|
||||||
"react-konva": "^18.2.10",
|
"react-konva": "^18.2.10",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "0.165.0",
|
"three": "0.165.0",
|
||||||
|
"zod": "^4.3.4",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
BIN
frontend/public/assets/logo-veridian.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
67
frontend/public/download.html
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Download Veridian App</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #09090b;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background-color: #10b981;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 20px 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
display: inline-block;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<img src="/assets/logo-veridian.png" alt="Logo" width="80" style="margin-bottom: 20px; border-radius: 16px;">
|
||||||
|
<h1>Veridian v2</h1>
|
||||||
|
<a href="/veridian-v2.apk" class="btn">Download APK (27MB)</a>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
1. Tap Download.<br>
|
||||||
|
2. Open file.<br>
|
||||||
|
3. Tap "Install".<br>
|
||||||
|
<br>
|
||||||
|
(If blocked, enable "Install from unknown sources")
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -67,8 +67,8 @@ export default function AnnouncementBanner() {
|
||||||
const getPriorityStyles = (priority: string) => {
|
const getPriorityStyles = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'CRITICAL': return 'bg-red-500/10 border-red-500/30';
|
case 'CRITICAL': return 'bg-red-500/10 border-red-500/30';
|
||||||
case 'WARNING': return 'bg-amber-500/10 border-amber-500/30';
|
case 'WARNING': return 'bg-[var(--color-warning)]/10 border-amber-500/30';
|
||||||
default: return 'bg-blue-500/10 border-blue-500/30';
|
default: return 'bg-[var(--color-accent)]/10 border-blue-500/30';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ export default function AnnouncementBanner() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!expanded && (
|
{!expanded && (
|
||||||
<p className="text-sm text-slate-400 truncate">{topAnnouncement.body}</p>
|
<p className="text-sm text-[var(--color-text-tertiary)] truncate">{topAnnouncement.body}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ export default function AnnouncementBanner() {
|
||||||
{topAnnouncement.requiresAck && !topAnnouncement.isAcknowledged && (
|
{topAnnouncement.requiresAck && !topAnnouncement.isAcknowledged && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAcknowledge(topAnnouncement.id); }}
|
onClick={(e) => { e.stopPropagation(); handleAcknowledge(topAnnouncement.id); }}
|
||||||
className="bg-emerald-500 hover:bg-emerald-600 text-white text-xs px-3 py-1.5 rounded flex items-center gap-1"
|
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)] text-white text-xs px-3 py-1.5 rounded flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Check size={14} />
|
<Check size={14} />
|
||||||
Acknowledge
|
Acknowledge
|
||||||
|
|
@ -118,12 +118,12 @@ export default function AnnouncementBanner() {
|
||||||
{!topAnnouncement.requiresAck && (
|
{!topAnnouncement.requiresAck && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleDismiss(topAnnouncement.id); }}
|
onClick={(e) => { e.stopPropagation(); handleDismiss(topAnnouncement.id); }}
|
||||||
className="text-slate-400 hover:text-white p-1"
|
className="text-[var(--color-text-tertiary)] hover:text-white p-1"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{expanded ? <ChevronUp size={18} className="text-slate-400" /> : <ChevronDown size={18} className="text-slate-400" />}
|
{expanded ? <ChevronUp size={18} className="text-[var(--color-text-tertiary)]" /> : <ChevronDown size={18} className="text-[var(--color-text-tertiary)]" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -141,13 +141,13 @@ export default function AnnouncementBanner() {
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="font-medium text-white">{a.title}</span>
|
<span className="font-medium text-white">{a.title}</span>
|
||||||
{a.isAcknowledged && (
|
{a.isAcknowledged && (
|
||||||
<span className="text-xs bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded">
|
<span className="text-xs bg-[var(--color-primary)]/20 text-emerald-400 px-2 py-0.5 rounded">
|
||||||
Acknowledged
|
Acknowledged
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-300">{a.body}</p>
|
<p className="text-sm text-slate-300">{a.body}</p>
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||||
By {a.createdBy.name} • {new Date(a.createdAt).toLocaleString()}
|
By {a.createdBy.name} • {new Date(a.createdAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -155,7 +155,7 @@ export default function AnnouncementBanner() {
|
||||||
{a.requiresAck && !a.isAcknowledged && (
|
{a.requiresAck && !a.isAcknowledged && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAcknowledge(a.id)}
|
onClick={() => handleAcknowledge(a.id)}
|
||||||
className="bg-emerald-500 hover:bg-emerald-600 text-white text-xs px-3 py-1.5 rounded flex items-center gap-1"
|
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)] text-white text-xs px-3 py-1.5 rounded flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Check size={14} />
|
<Check size={14} />
|
||||||
Acknowledge
|
Acknowledge
|
||||||
|
|
@ -164,7 +164,7 @@ export default function AnnouncementBanner() {
|
||||||
{!a.requiresAck && !a.isRead && (
|
{!a.requiresAck && !a.isRead && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDismiss(a.id)}
|
onClick={() => handleDismiss(a.id)}
|
||||||
className="text-slate-400 hover:text-white p-1"
|
className="text-[var(--color-text-tertiary)] hover:text-white p-1"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -55,33 +55,33 @@ export default function BatchTransitionModal({ batch, onClose, onSuccess }: Tran
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
||||||
<div className="bg-white dark:bg-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
|
<div className="bg-[var(--color-bg-elevated)] w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
|
||||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
|
<div className="p-6 border-b border-[var(--color-border-default)] flex justify-between items-center">
|
||||||
<h2 className="text-xl font-bold dark:text-white">Transition Batch</h2>
|
<h2 className="text-xl font-bold dark:text-white">Transition Batch</h2>
|
||||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
<button onClick={onClose} className="text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
<div className="bg-slate-50 dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
<div className="bg-[var(--color-bg-tertiary)] p-4 rounded-xl border border-[var(--color-border-default)]">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Sprout className="text-emerald-600 dark:text-emerald-400" size={20} />
|
<Sprout className="text-[var(--color-primary)] dark:text-emerald-400" size={20} />
|
||||||
<span className="font-semibold text-slate-900 dark:text-white">{batch.name}</span>
|
<span className="font-semibold text-[var(--color-text-primary)]">{batch.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
<div className="flex items-center gap-2 text-sm text-[var(--color-text-tertiary)]">
|
||||||
<span>{STAGES.find(s => s.id === batch.stage)?.label}</span>
|
<span>{STAGES.find(s => s.id === batch.stage)?.label}</span>
|
||||||
<ArrowRight size={14} />
|
<ArrowRight size={14} />
|
||||||
<span className="text-emerald-600 font-medium">{STAGES.find(s => s.id === targetStage)?.label}</span>
|
<span className="text-[var(--color-primary)] font-medium">{STAGES.find(s => s.id === targetStage)?.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">New Stage</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">New Stage</label>
|
||||||
<select
|
<select
|
||||||
value={targetStage}
|
value={targetStage}
|
||||||
onChange={(e) => setTargetStage(e.target.value as any)}
|
onChange={(e) => setTargetStage(e.target.value as any)}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
>
|
>
|
||||||
{STAGES.map(stage => (
|
{STAGES.map(stage => (
|
||||||
<option key={stage.id} value={stage.id}>{stage.label}</option>
|
<option key={stage.id} value={stage.id}>{stage.label}</option>
|
||||||
|
|
@ -90,7 +90,7 @@ export default function BatchTransitionModal({ batch, onClose, onSuccess }: Tran
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Home size={16} />
|
<Home size={16} />
|
||||||
Move to Room
|
Move to Room
|
||||||
|
|
@ -99,7 +99,7 @@ export default function BatchTransitionModal({ batch, onClose, onSuccess }: Tran
|
||||||
<select
|
<select
|
||||||
value={targetRoomId}
|
value={targetRoomId}
|
||||||
onChange={(e) => setTargetRoomId(e.target.value)}
|
onChange={(e) => setTargetRoomId(e.target.value)}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="">Keep current room ({batch.room?.name || 'Unassigned'})</option>
|
<option value="">Keep current room ({batch.room?.name || 'Unassigned'})</option>
|
||||||
{rooms.map(room => (
|
{rooms.map(room => (
|
||||||
|
|
@ -110,19 +110,19 @@ export default function BatchTransitionModal({ batch, onClose, onSuccess }: Tran
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Plant Count</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Plant Count</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={metadata.plantCount}
|
value={metadata.plantCount}
|
||||||
onChange={(e) => setMetadata({ ...metadata, plantCount: parseInt(e.target.value) || 0 })}
|
onChange={(e) => setMetadata({ ...metadata, plantCount: parseInt(e.target.value) || 0 })}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2"
|
className="w-full py-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
Confirm Transition
|
Confirm Transition
|
||||||
<ArrowRight size={20} />
|
<ArrowRight size={20} />
|
||||||
|
|
|
||||||
|
|
@ -48,27 +48,27 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-[var(--color-bg-tertiary)] flex items-center justify-center p-6">
|
||||||
<div className="max-w-md w-full bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-8 text-center">
|
<div className="max-w-md w-full bg-[var(--color-bg-elevated)] rounded-2xl shadow-xl p-8 text-center">
|
||||||
<div className="w-16 h-16 mx-auto bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-6">
|
<div className="w-16 h-16 mx-auto bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-6">
|
||||||
<AlertTriangle size={32} className="text-red-600 dark:text-red-400" />
|
<AlertTriangle size={32} className="text-red-600 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-2">
|
||||||
Something went wrong
|
Something went wrong
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-slate-500 dark:text-slate-400 mb-6">
|
<p className="text-[var(--color-text-tertiary)] mb-6">
|
||||||
An unexpected error occurred. Don't worry, your data is safe.
|
An unexpected error occurred. Don't worry, your data is safe.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Error Details (collapsible in production) */}
|
{/* Error Details (collapsible in production) */}
|
||||||
{import.meta.env.DEV && this.state.error && (
|
{import.meta.env.DEV && this.state.error && (
|
||||||
<details className="mb-6 text-left">
|
<details className="mb-6 text-left">
|
||||||
<summary className="cursor-pointer text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
<summary className="cursor-pointer text-sm text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300">
|
||||||
Technical Details
|
Technical Details
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 p-3 bg-slate-100 dark:bg-slate-700 rounded-lg text-xs text-red-600 dark:text-red-400 overflow-auto max-h-32">
|
<pre className="mt-2 p-3 bg-[var(--color-bg-tertiary)] rounded-lg text-xs text-red-600 dark:text-red-400 overflow-auto max-h-32">
|
||||||
{this.state.error.toString()}
|
{this.state.error.toString()}
|
||||||
{this.state.errorInfo?.componentStack}
|
{this.state.errorInfo?.componentStack}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
@ -78,14 +78,14 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={this.handleRetry}
|
onClick={this.handleRetry}
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-xl transition-colors"
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-xl transition-colors"
|
||||||
>
|
>
|
||||||
<RefreshCw size={18} />
|
<RefreshCw size={18} />
|
||||||
Try Again
|
Try Again
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={this.handleGoHome}
|
onClick={this.handleGoHome}
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-xl transition-colors"
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-[var(--color-text-secondary)]late-200 font-medium rounded-xl transition-colors"
|
||||||
>
|
>
|
||||||
<Home size={18} />
|
<Home size={18} />
|
||||||
Go Home
|
Go Home
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default function InfoTooltip({ content, size = 16 }: InfoTooltipProps) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsVisible(!isVisible);
|
setIsVisible(!isVisible);
|
||||||
}}
|
}}
|
||||||
className="text-slate-400 hover:text-emerald-500 focus:outline-none transition-colors"
|
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] focus:outline-none transition-colors"
|
||||||
aria-label="More information"
|
aria-label="More information"
|
||||||
>
|
>
|
||||||
<HelpCircle size={size} />
|
<HelpCircle size={size} />
|
||||||
|
|
|
||||||
|
|
@ -15,38 +15,39 @@ 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';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [mobileSheetOpen, setMobileSheetOpen] = useState(false);
|
const [mobileSheetOpen, setMobileSheetOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-white dark:bg-[#0B0E14] text-slate-900 dark:text-slate-100 overflow-hidden font-sans">
|
<div className="flex h-screen bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] overflow-hidden font-sans">
|
||||||
{/* Accessibility: Skip to main content */}
|
{/* Accessibility: Skip to main content */}
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
href="#main-content"
|
||||||
className="absolute left-0 top-0 -translate-y-full bg-emerald-600 text-white px-4 py-2 rounded-br-lg font-medium focus:translate-y-0 z-50 transition-transform duration-fast"
|
className="absolute left-0 top-0 -translate-y-full bg-[var(--color-primary)] text-white px-4 py-2 rounded-br-lg font-medium focus:translate-y-0 z-50 transition-transform duration-fast"
|
||||||
>
|
>
|
||||||
Skip to main content
|
Skip to main content
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Desktop Sidebar - Persistent on left */}
|
{/* Desktop Sidebar - Persistent on left */}
|
||||||
<aside className="hidden lg:flex flex-col w-64 border-r border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-[#0B0E14] z-30">
|
<aside className="hidden lg:flex flex-col w-[260px] border-r border-[var(--color-border-subtle)] bg-[var(--color-bg-secondary)] z-30">
|
||||||
<div className="h-16 flex items-center px-6 border-b border-slate-200 dark:border-slate-800">
|
<div className="h-16 flex items-center px-6 border-b border-[var(--color-border-subtle)]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center text-white font-bold text-xl italic shadow-lg shadow-emerald-500/20">
|
<div className="w-9 h-9 rounded-xl bg-[var(--color-primary)] flex items-center justify-center text-[var(--color-text-inverse)] font-bold text-lg shadow-lg shadow-emerald-500/20">
|
||||||
V
|
V
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-bold uppercase tracking-widest leading-none">Veridian</span>
|
<span className="text-sm font-bold tracking-tight leading-none text-[var(--color-text-primary)]">Veridian</span>
|
||||||
<span className="text-[10px] text-slate-500 font-medium uppercase tracking-tighter leading-none mt-0.5">Platform v2.0</span>
|
<span className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase tracking-wider leading-none mt-0.5">Cultivation Platform</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto pt-4">
|
<div className="flex-1 overflow-y-auto pt-4">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t border-slate-200 dark:border-slate-800">
|
<div className="p-4 border-t border-[var(--color-border-subtle)]">
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -54,48 +55,45 @@ 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-slate-200 dark:border-slate-800 bg-white/50 dark:bg-[#0B0E14]/50 backdrop-blur-md 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">
|
||||||
<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-lg bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-800 transition-colors">
|
<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">
|
||||||
<span className="text-xs font-bold uppercase tracking-widest text-slate-500">Facility</span>
|
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)]">Facility</span>
|
||||||
<span className="text-xs font-bold">NORCAL-01</span>
|
<span className="text-xs font-bold text-[var(--color-text-primary)]">NORCAL-01</span>
|
||||||
<ChevronDown size={14} className="text-slate-500" />
|
<ChevronDown size={14} className="text-[var(--color-text-tertiary)]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global Search */}
|
{/* Global Search */}
|
||||||
<div className="relative group max-w-sm w-full hidden sm:block">
|
<div className="relative group max-w-sm w-full hidden sm:block">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-500 transition-colors" size={16} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] group-focus-within:text-[var(--color-primary)] transition-colors" size={16} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search batches, rooms, tasks..."
|
placeholder="Search batches, rooms, tasks..."
|
||||||
className="w-full bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition-all"
|
className="w-full bg-[var(--color-bg-tertiary)] border border-[var(--color-border-subtle)] rounded-full pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)] transition-all"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-50">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-50">
|
||||||
<kbd className="text-[10px] font-mono">⌘</kbd>
|
<kbd className="text-[10px] font-mono text-[var(--color-text-tertiary)]">⌘</kbd>
|
||||||
<kbd className="text-[10px] font-mono">K</kbd>
|
<kbd className="text-[10px] font-mono text-[var(--color-text-tertiary)]">K</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="hidden md:flex items-center gap-4 mr-4 text-[11px] font-bold uppercase tracking-widest text-slate-500">
|
<div className="hidden md:flex items-center gap-4 mr-4 text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
<div className="w-2 h-2 rounded-full bg-[var(--color-success)] animate-pulse" />
|
||||||
<span>Terminal Live</span>
|
<span>Terminal Live</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
<div className="w-2 h-2 rounded-full bg-[var(--color-accent)]" />
|
||||||
<span>32 Sensors Active</span>
|
<span>32 Sensors Active</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button className="relative p-2 text-slate-500 hover:text-emerald-500 transition-colors rounded-lg hover:bg-slate-100 dark:hover:bg-slate-900">
|
<NotificationBell />
|
||||||
<Bell size={20} />
|
<button className="lg:hidden p-2 text-[var(--color-text-tertiary)]" onClick={() => setMobileSheetOpen(true)}>
|
||||||
<span className="absolute top-2 right-2 w-2 h-2 bg-rose-500 rounded-full ring-2 ring-white dark:ring-[#0B0E14]" />
|
|
||||||
</button>
|
|
||||||
<button className="lg:hidden p-2 text-slate-500" onClick={() => setMobileSheetOpen(true)}>
|
|
||||||
<Filter size={20} />
|
<Filter size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,7 +108,7 @@ export default function Layout() {
|
||||||
<PageTitleUpdater />
|
<PageTitleUpdater />
|
||||||
<AnnouncementBanner />
|
<AnnouncementBanner />
|
||||||
|
|
||||||
<div className="max-w-[1920px] mx-auto p-4 sm:p-6 lg:p-8">
|
<div className="max-w-[1920px] mx-auto p-4 pb-24 sm:p-6 sm:pb-24 md:pb-8 lg:p-8">
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center">
|
<div className="min-h-screen bg-[var(--color-bg-tertiary)] flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -50,27 +50,27 @@ export default function TaskTemplateModal({ template, onClose, onSuccess }: Task
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
||||||
<div className="bg-white dark:bg-slate-800 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-scale-in flex flex-col max-h-[90vh]">
|
<div className="bg-[var(--color-bg-elevated)] w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-scale-in flex flex-col max-h-[90vh]">
|
||||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
|
<div className="p-6 border-b border-[var(--color-border-default)] flex justify-between items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="text-emerald-600 dark:text-emerald-400" size={24} />
|
<FileText className="text-[var(--color-primary)] dark:text-emerald-400" size={24} />
|
||||||
<h2 className="text-xl font-bold dark:text-white">
|
<h2 className="text-xl font-bold dark:text-white">
|
||||||
{template ? 'Edit Template' : 'New Task Template'}
|
{template ? 'Edit Template' : 'New Task Template'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
<button onClick={onClose} className="text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto flex-1">
|
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Title</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
placeholder="e.g. Daily Veg Watering"
|
placeholder="e.g. Daily Veg Watering"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -78,11 +78,11 @@ export default function TaskTemplateModal({ template, onClose, onSuccess }: Task
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Room Type</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Room Type</label>
|
||||||
<select
|
<select
|
||||||
value={formData.roomType}
|
value={formData.roomType}
|
||||||
onChange={(e) => setFormData({ ...formData, roomType: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, roomType: e.target.value })}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
>
|
>
|
||||||
{ROOM_TYPES.map(type => (
|
{ROOM_TYPES.map(type => (
|
||||||
<option key={type} value={type}>{type}</option>
|
<option key={type} value={type}>{type}</option>
|
||||||
|
|
@ -90,43 +90,43 @@ export default function TaskTemplateModal({ template, onClose, onSuccess }: Task
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Est. Minutes</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Est. Minutes</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.estimatedMinutes}
|
value={formData.estimatedMinutes}
|
||||||
onChange={(e) => setFormData({ ...formData, estimatedMinutes: parseInt(e.target.value) || 0 })}
|
onChange={(e) => setFormData({ ...formData, estimatedMinutes: parseInt(e.target.value) || 0 })}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Description / Instructions</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Description / Instructions</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white h-32 resize-none"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white h-32 resize-none"
|
||||||
placeholder="Detailed SOP instructions..."
|
placeholder="Detailed SOP instructions..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Materials (comma separated)</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Materials (comma separated)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={materialsInput}
|
value={materialsInput}
|
||||||
onChange={(e) => setMaterialsInput(e.target.value)}
|
onChange={(e) => setMaterialsInput(e.target.value)}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
placeholder="e.g. 5gal buckets, bamboo stakes, twist ties"
|
placeholder="e.g. 5gal buckets, bamboo stakes, twist ties"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="p-6 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
|
<div className="p-6 border-t border-[var(--color-border-default)] bg-[var(--color-bg-tertiary)]/50">
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-all"
|
className="w-full py-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-all"
|
||||||
>
|
>
|
||||||
<Save size={20} />
|
<Save size={20} />
|
||||||
{isSubmitting ? 'Saving...' : 'Save Template'}
|
{isSubmitting ? 'Saving...' : 'Save Template'}
|
||||||
|
|
|
||||||
|
|
@ -45,29 +45,29 @@ export default function WeightLogModal({ batch, onClose, onSuccess }: WeightLogM
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
||||||
<div className="bg-white dark:bg-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
|
<div className="bg-[var(--color-bg-elevated)] w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
|
||||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
|
<div className="p-6 border-b border-[var(--color-border-default)] flex justify-between items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Scale className="text-emerald-600 dark:text-emerald-400" size={24} />
|
<Scale className="text-[var(--color-primary)] dark:text-emerald-400" size={24} />
|
||||||
<h2 className="text-xl font-bold dark:text-white">Log Weight</h2>
|
<h2 className="text-xl font-bold dark:text-white">Log Weight</h2>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
<button onClick={onClose} className="text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
<div className="bg-slate-50 dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
<div className="bg-[var(--color-bg-tertiary)] p-4 rounded-xl border border-[var(--color-border-default)]">
|
||||||
<h3 className="font-semibold text-slate-900 dark:text-white">{batch.name}</h3>
|
<h3 className="font-semibold text-[var(--color-text-primary)]">{batch.name}</h3>
|
||||||
<p className="text-sm text-slate-500">{batch.strain}</p>
|
<p className="text-sm text-[var(--color-text-tertiary)]">{batch.strain}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Weight Type</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Weight Type</label>
|
||||||
<select
|
<select
|
||||||
value={weightType}
|
value={weightType}
|
||||||
onChange={(e) => setWeightType(e.target.value)}
|
onChange={(e) => setWeightType(e.target.value)}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
>
|
>
|
||||||
{WEIGHT_TYPES.map(type => (
|
{WEIGHT_TYPES.map(type => (
|
||||||
<option key={type.id} value={type.id}>{type.label}</option>
|
<option key={type.id} value={type.id}>{type.label}</option>
|
||||||
|
|
@ -77,23 +77,23 @@ export default function WeightLogModal({ batch, onClose, onSuccess }: WeightLogM
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Weight</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Weight</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={weight}
|
value={weight}
|
||||||
onChange={(e) => setWeight(e.target.value)}
|
onChange={(e) => setWeight(e.target.value)}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Unit</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Unit</label>
|
||||||
<select
|
<select
|
||||||
value={unit}
|
value={unit}
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="lbs">lbs</option>
|
<option value="lbs">lbs</option>
|
||||||
<option value="g">grams</option>
|
<option value="g">grams</option>
|
||||||
|
|
@ -104,11 +104,11 @@ export default function WeightLogModal({ batch, onClose, onSuccess }: WeightLogM
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Notes</label>
|
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white h-24 resize-none"
|
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white h-24 resize-none"
|
||||||
placeholder="Optional notes..."
|
placeholder="Optional notes..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -116,7 +116,7 @@ export default function WeightLogModal({ batch, onClose, onSuccess }: WeightLogM
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2 disabled:opacity-50"
|
className="w-full py-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Save size={20} />
|
<Save size={20} />
|
||||||
{isSubmitting ? 'Saving...' : 'Save Weight Log'}
|
{isSubmitting ? 'Saving...' : 'Save Weight Log'}
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,8 @@ export const BentoCard = ({
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-900 group-hover:bg-cyan-50 dark:group-hover:bg-cyan-950/30 transition-colors duration-300">
|
<div className="p-2 rounded-lg bg-[var(--color-bg-tertiary)] group-hover:bg-cyan-50 dark:group-hover:bg-cyan-950/30 transition-colors duration-300">
|
||||||
<Icon className="h-5 w-5 text-slate-600 dark:text-slate-400 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors" />
|
<Icon className="h-5 w-5 text-slate-600 dark:text-[var(--color-text-tertiary)] group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-slate-900 dark:text-slate-50 text-lg">
|
<h3 className="font-semibold text-slate-900 dark:text-slate-50 text-lg">
|
||||||
{name}
|
{name}
|
||||||
|
|
@ -86,14 +86,14 @@ export const BentoCard = ({
|
||||||
{/* Metric Value (if provided) - Large display */}
|
{/* Metric Value (if provided) - Large display */}
|
||||||
{value && (
|
{value && (
|
||||||
<div className="mt-4 mb-2">
|
<div className="mt-4 mb-2">
|
||||||
<span className="text-4xl font-bold text-slate-900 dark:text-white tracking-tight">
|
<span className="text-4xl font-bold text-[var(--color-text-primary)] tracking-tight">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description / Subtext */}
|
{/* Description / Subtext */}
|
||||||
<p className="text-slate-500 dark:text-slate-400 text-sm leading-relaxed max-w-[90%]">
|
<p className="text-[var(--color-text-tertiary)] text-sm leading-relaxed max-w-[90%]">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const Feature3D = ({ onEnter }: Feature3DProps) => {
|
||||||
<h2 className="text-4xl md:text-5xl font-bold tracking-tight mb-6 bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-purple-500">
|
<h2 className="text-4xl md:text-5xl font-bold tracking-tight mb-6 bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-purple-500">
|
||||||
Digital Twin <br /> Visualization
|
Digital Twin <br /> Visualization
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-slate-400 mb-8 leading-relaxed">
|
<p className="text-xl text-[var(--color-text-tertiary)] mb-8 leading-relaxed">
|
||||||
Experience your facility like never before. Our real-time 3D engine maps every sensor, plant, and compliance event to its exact physical location.
|
Experience your facility like never before. Our real-time 3D engine maps every sensor, plant, and compliance event to its exact physical location.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ const Hero1 = () => {
|
||||||
</>
|
</>
|
||||||
) : isSelected ? (
|
) : isSelected ? (
|
||||||
<>
|
<>
|
||||||
<Copy className="h-3 w-3 text-blue-500" />
|
<Copy className="h-3 w-3 text-[var(--color-accent)]" />
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Analyzing Input
|
Analyzing Input
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,13 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
||||||
alt="Veridian"
|
alt="Veridian"
|
||||||
className="w-9 h-9 rounded-lg shadow-md ring-1 ring-slate-900/5 group-hover:scale-105 transition-transform duration-500"
|
className="w-9 h-9 rounded-lg shadow-md ring-1 ring-slate-900/5 group-hover:scale-105 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-white dark:border-[#050505] animate-pulse" />
|
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-[var(--color-primary)] rounded-full border-2 border-white dark:border-[#050505] animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight tracking-tighter uppercase italic">
|
<h1 className="text-sm font-bold text-[var(--color-text-primary)] leading-tight tracking-tighter uppercase italic">
|
||||||
Veridian
|
Veridian
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[9px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-[0.3em] leading-none">
|
<p className="text-[9px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.3em] leading-none">
|
||||||
Cultivation Platform
|
Cultivation Platform
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -82,20 +82,20 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
||||||
{/* Search Mock */}
|
{/* Search Mock */}
|
||||||
<button
|
<button
|
||||||
onClick={() => dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
|
onClick={() => dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
|
||||||
className="hidden md:flex items-center gap-3 px-4 py-2 text-xs font-bold text-slate-500 bg-slate-100/50 hover:bg-white dark:bg-slate-900/50 dark:hover:bg-slate-800 transition-all rounded-full border border-slate-200/50 dark:border-slate-800 group"
|
className="hidden md:flex items-center gap-3 px-4 py-2 text-xs font-bold text-[var(--color-text-tertiary)] bg-slate-100/50 hover:bg-[var(--color-bg-elevated)]/50 dark:hover:bg-slate-800 transition-all rounded-full border border-slate-200/50 dark:border-slate-800 group"
|
||||||
>
|
>
|
||||||
<Search size={12} className="group-hover:text-indigo-500 transition-colors" />
|
<Search size={12} className="group-hover:text-indigo-500 transition-colors" />
|
||||||
<span className="uppercase tracking-widest">Search...</span>
|
<span className="uppercase tracking-widest">Search...</span>
|
||||||
<kbd className="hidden lg:inline-flex h-5 items-center gap-1 rounded bg-slate-200 dark:bg-slate-800 px-1.5 font-mono text-[9px] text-slate-500">
|
<kbd className="hidden lg:inline-flex h-5 items-center gap-1 rounded bg-slate-200 dark:bg-slate-800 px-1.5 font-mono text-[9px] text-[var(--color-text-tertiary)]">
|
||||||
⌘K
|
⌘K
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
|
<div className="w-px h-6 bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
|
||||||
|
|
||||||
<button className="relative p-2 text-slate-400 hover:text-indigo-500 transition-colors rounded-xl hover:bg-slate-100 dark:hover:bg-slate-900">
|
<button className="relative p-2 text-[var(--color-text-tertiary)] hover:text-indigo-500 transition-colors rounded-xl hover:bg-slate-100 dark:hover:bg-slate-900">
|
||||||
<Bell size={18} />
|
<Bell size={18} />
|
||||||
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-rose-500 rounded-full ring-2 ring-white dark:ring-[#050505]" />
|
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-[var(--color-error)] rounded-full ring-2 ring-white dark:ring-[#050505]" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
@ -106,7 +106,7 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onOpenMobileMenu}
|
onClick={onOpenMobileMenu}
|
||||||
className="lg:hidden p-2 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
className="lg:hidden p-2 text-[var(--color-text-tertiary)] hover:text-slate-900 dark:text-[var(--color-text-tertiary)] dark:hover:text-white"
|
||||||
>
|
>
|
||||||
<Menu size={24} />
|
<Menu size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -150,14 +150,14 @@ function UserDropdown() {
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
transition={{ duration: 0.1 }}
|
transition={{ duration: 0.1 }}
|
||||||
className="absolute top-full right-0 mt-3 w-64 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50 ring-1 ring-black/5"
|
className="absolute top-full right-0 mt-3 w-64 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-[var(--color-border-subtle)] rounded-2xl shadow-2xl z-50 ring-1 ring-black/5"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-3 border-b border-slate-100 dark:border-slate-800 mb-2">
|
<div className="px-3 py-3 border-b border-[var(--color-border-subtle)] mb-2">
|
||||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Authenticated</p>
|
<p className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest mb-1">Authenticated</p>
|
||||||
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">
|
<p className="text-sm font-bold text-[var(--color-text-primary)] truncate">
|
||||||
{user?.name || 'Administrator'}
|
{user?.name || 'Administrator'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-slate-500 truncate font-mono mt-1">
|
<p className="text-[10px] text-[var(--color-text-tertiary)] truncate font-mono mt-1">
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -165,7 +165,7 @@ function UserDropdown() {
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 dark:text-slate-400 dark:hover:text-indigo-400 dark:hover:bg-indigo-500/10 rounded-xl transition-all"
|
className="flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 dark:text-[var(--color-text-tertiary)] dark:hover:text-indigo-400 dark:hover:bg-indigo-500/10 rounded-xl transition-all"
|
||||||
>
|
>
|
||||||
<Settings size={14} />
|
<Settings size={14} />
|
||||||
Settings
|
Settings
|
||||||
|
|
@ -204,7 +204,7 @@ function NavDropdown({ section, currentPath }: { section: NavSection, currentPat
|
||||||
flex items-center gap-2 px-3.5 py-2 text-[11px] font-bold uppercase tracking-[0.15em] rounded-lg transition-all
|
flex items-center gap-2 px-3.5 py-2 text-[11px] font-bold uppercase tracking-[0.15em] rounded-lg transition-all
|
||||||
${isActive || isOpen
|
${isActive || isOpen
|
||||||
? 'text-indigo-600 bg-indigo-500/5 dark:text-indigo-400 dark:bg-indigo-500/10'
|
? 'text-indigo-600 bg-indigo-500/5 dark:text-indigo-400 dark:bg-indigo-500/10'
|
||||||
: 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
|
: 'text-[var(--color-text-tertiary)] hover:text-slate-900 dark:text-[var(--color-text-tertiary)] dark:hover:text-white'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|
@ -222,10 +222,10 @@ function NavDropdown({ section, currentPath }: { section: NavSection, currentPat
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="absolute top-full left-0 mt-2 w-72 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50"
|
className="absolute top-full left-0 mt-2 w-72 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-[var(--color-border-subtle)] rounded-2xl shadow-2xl z-50"
|
||||||
>
|
>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
<p className="px-3 py-1.5 text-[9px] font-bold text-slate-400 uppercase tracking-[0.2em]">{section.label}</p>
|
<p className="px-3 py-1.5 text-[9px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.2em]">{section.label}</p>
|
||||||
{section.items.map(item => (
|
{section.items.map(item => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|
@ -234,13 +234,13 @@ function NavDropdown({ section, currentPath }: { section: NavSection, currentPat
|
||||||
group flex items-center gap-4 px-3 py-3 rounded-xl transition-all
|
group flex items-center gap-4 px-3 py-3 rounded-xl transition-all
|
||||||
${item.path === currentPath
|
${item.path === currentPath
|
||||||
? 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
|
? 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
|
||||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100'
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-[var(--color-text-tertiary)] dark:hover:bg-slate-800 dark:hover:text-slate-100'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"p-2 rounded-lg transition-all group-hover:scale-110",
|
"p-2 rounded-lg transition-all group-hover:scale-110",
|
||||||
item.path === currentPath ? "bg-indigo-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400"
|
item.path === currentPath ? "bg-indigo-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-[var(--color-text-tertiary)]"
|
||||||
)}>
|
)}>
|
||||||
<item.icon size={14} />
|
<item.icon size={14} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ const commonStyles = {
|
||||||
input:
|
input:
|
||||||
"w-full py-3.5 pl-12 pr-4 text-sm text-slate-900 placeholder-transparent bg-white/70 backdrop-blur-md border border-slate-200 rounded-full focus:outline-none focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500 peer transition-all duration-300 disabled:opacity-50",
|
"w-full py-3.5 pl-12 pr-4 text-sm text-slate-900 placeholder-transparent bg-white/70 backdrop-blur-md border border-slate-200 rounded-full focus:outline-none focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500 peer transition-all duration-300 disabled:opacity-50",
|
||||||
inputIcon:
|
inputIcon:
|
||||||
"absolute left-4 text-slate-500 peer-focus:text-cyan-500 transition-colors w-5 h-5",
|
"absolute left-4 text-[var(--color-text-tertiary)] peer-focus:text-cyan-500 transition-colors w-5 h-5",
|
||||||
label:
|
label:
|
||||||
"absolute left-12 text-slate-500 text-sm transition-all duration-200 peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-slate-400 peer-placeholder-shown:text-base peer-focus:top-1.5 peer-focus:text-xs peer-focus:text-cyan-600",
|
"absolute left-12 text-[var(--color-text-tertiary)] text-sm transition-all duration-200 peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-[var(--color-text-tertiary)] peer-placeholder-shown:text-base peer-focus:top-1.5 peer-focus:text-xs peer-focus:text-cyan-600",
|
||||||
button:
|
button:
|
||||||
"w-full py-3.5 px-6 font-semibold rounded-full bg-gradient-to-r from-cyan-600 to-blue-600 text-white shadow-lg hover:opacity-90 hover:shadow-cyan-500/30 transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed",
|
"w-full py-3.5 px-6 font-semibold rounded-full bg-gradient-to-r from-cyan-600 to-blue-600 text-white shadow-lg hover:opacity-90 hover:shadow-cyan-500/30 transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
link: "font-medium text-cyan-600 hover:text-cyan-700 transition-colors hover:underline",
|
link: "font-medium text-cyan-600 hover:text-cyan-700 transition-colors hover:underline",
|
||||||
|
|
@ -98,7 +98,7 @@ export const VisitorKioskShell = ({ children, onBack, title, subtitle }: Visitor
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="absolute top-8 left-8 hidden lg:flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors"
|
className="absolute top-8 left-8 hidden lg:flex items-center gap-2 text-[var(--color-text-tertiary)] hover:text-slate-600 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} /> Back
|
<ArrowLeft size={20} /> Back
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -350,7 +350,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
||||||
{/* Visitor Type Selector */}
|
{/* Visitor Type Selector */}
|
||||||
<div className={commonStyles.inputWrapper}>
|
<div className={commonStyles.inputWrapper}>
|
||||||
<Shield className={commonStyles.inputIcon} />
|
<Shield className={commonStyles.inputIcon} />
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-[var(--color-text-tertiary)]">
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} />
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
|
|
@ -365,7 +365,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
||||||
<option value="VENDOR">Vendor</option>
|
<option value="VENDOR">Vendor</option>
|
||||||
<option value="DELIVERY">Delivery</option>
|
<option value="DELIVERY">Delivery</option>
|
||||||
</select>
|
</select>
|
||||||
<label className="absolute left-12 top-1.5 text-xs text-slate-500 font-medium transition-all duration-200">
|
<label className="absolute left-12 top-1.5 text-xs text-[var(--color-text-tertiary)] font-medium transition-all duration-200">
|
||||||
Visit Type
|
Visit Type
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -376,7 +376,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startCamera}
|
onClick={startCamera}
|
||||||
className="w-full h-32 flex flex-col items-center justify-center gap-2 text-slate-400 hover:text-cyan-600 transition-colors"
|
className="w-full h-32 flex flex-col items-center justify-center gap-2 text-[var(--color-text-tertiary)] hover:text-cyan-600 transition-colors"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Camera className="w-8 h-8 opacity-50 group-hover:opacity-100" />
|
<Camera className="w-8 h-8 opacity-50 group-hover:opacity-100" />
|
||||||
|
|
@ -429,7 +429,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<span className="bg-emerald-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 shadow-sm">
|
<span className="bg-[var(--color-primary)] text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 shadow-sm">
|
||||||
<CheckCircle size={10} /> Photo Ready
|
<CheckCircle size={10} /> Photo Ready
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -451,7 +451,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
||||||
/>
|
/>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-slate-700 font-medium">Agreement Required</span>
|
<span className="text-slate-700 font-medium">Agreement Required</span>
|
||||||
<p className="text-slate-500 text-xs mt-0.5">I agree to the Veridian Non-Disclosure Agreement and safety policies.</p>
|
<p className="text-[var(--color-text-tertiary)] text-xs mt-0.5">I agree to the Veridian Non-Disclosure Agreement and safety policies.</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
|
||||||
246
frontend/src/components/cameras/CameraWidget.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Camera as CameraIcon, Video, VideoOff, ChevronRight, Eye, Settings, AlertCircle } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { VideoPlayer } from './VideoPlayer';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface Camera {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
streamKey: string;
|
||||||
|
location?: string;
|
||||||
|
status: 'ONLINE' | 'OFFLINE' | 'IDLE' | 'STREAMING' | 'ERROR';
|
||||||
|
manufacturer?: string;
|
||||||
|
room?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CameraWidgetProps {
|
||||||
|
cameras?: Camera[];
|
||||||
|
maxVisible?: number;
|
||||||
|
onCameraClick?: (camera: Camera) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard widget showing camera grid with live previews
|
||||||
|
*/
|
||||||
|
export function CameraWidget({ cameras = [], maxVisible = 4, onCameraClick }: CameraWidgetProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [selectedCamera, setSelectedCamera] = useState<Camera | null>(null);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const visibleCameras = cameras.slice(0, maxVisible);
|
||||||
|
const remainingCount = cameras.length - maxVisible;
|
||||||
|
|
||||||
|
const handleCameraClick = (camera: Camera) => {
|
||||||
|
if (onCameraClick) {
|
||||||
|
onCameraClick(camera);
|
||||||
|
} else {
|
||||||
|
setSelectedCamera(camera);
|
||||||
|
setShowModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: Camera['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'ONLINE':
|
||||||
|
case 'STREAMING':
|
||||||
|
return 'bg-emerald-500';
|
||||||
|
case 'IDLE':
|
||||||
|
return 'bg-amber-500';
|
||||||
|
case 'OFFLINE':
|
||||||
|
return 'bg-gray-400';
|
||||||
|
case 'ERROR':
|
||||||
|
return 'bg-red-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: Camera['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'STREAMING':
|
||||||
|
return 'Live';
|
||||||
|
case 'IDLE':
|
||||||
|
return 'Ready';
|
||||||
|
default:
|
||||||
|
return status.charAt(0) + status.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-2xl",
|
||||||
|
"bg-white dark:bg-zinc-900/80",
|
||||||
|
"border border-gray-200 dark:border-zinc-700/50",
|
||||||
|
"shadow-sm dark:shadow-xl"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-5 pb-4 flex items-center justify-between border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400">
|
||||||
|
<Video size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 dark:text-white tracking-wide">
|
||||||
|
Security Cameras
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-zinc-500">
|
||||||
|
{cameras.filter(c => c.status !== 'OFFLINE').length} of {cameras.length} online
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/cameras')}
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-500 dark:text-zinc-500 dark:hover:text-emerald-400 transition-colors"
|
||||||
|
>
|
||||||
|
View All
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera Grid */}
|
||||||
|
{cameras.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<CameraIcon className="w-12 h-12 text-gray-300 dark:text-zinc-700 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-gray-500 dark:text-zinc-500">No cameras configured</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/settings/cameras')}
|
||||||
|
className="mt-3 text-xs text-emerald-600 dark:text-emerald-400 hover:underline"
|
||||||
|
>
|
||||||
|
Add Camera
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 grid grid-cols-2 gap-3">
|
||||||
|
{visibleCameras.map((camera) => (
|
||||||
|
<motion.button
|
||||||
|
key={camera.id}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={() => handleCameraClick(camera)}
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-video rounded-xl overflow-hidden cursor-pointer transition-all",
|
||||||
|
"bg-gray-100 dark:bg-zinc-800",
|
||||||
|
"border border-gray-200 dark:border-zinc-700",
|
||||||
|
"hover:border-emerald-500/50 hover:shadow-lg hover:shadow-emerald-500/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Placeholder/Thumbnail */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
{camera.status === 'OFFLINE' || camera.status === 'ERROR' ? (
|
||||||
|
<VideoOff className="w-8 h-8 text-gray-400 dark:text-zinc-600" />
|
||||||
|
) : (
|
||||||
|
<CameraIcon className="w-8 h-8 text-gray-400 dark:text-zinc-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="absolute top-2 left-2 flex items-center gap-1.5 px-2 py-0.5 bg-black/60 rounded-full">
|
||||||
|
<div className={cn("w-1.5 h-1.5 rounded-full", getStatusColor(camera.status))} />
|
||||||
|
<span className="text-[10px] font-medium text-white">
|
||||||
|
{getStatusText(camera.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera Name */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
|
||||||
|
<p className="text-xs font-medium text-white truncate">{camera.name}</p>
|
||||||
|
{camera.room && (
|
||||||
|
<p className="text-[10px] text-gray-300 truncate">{camera.room.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-emerald-500/0 hover:bg-emerald-500/10 transition-colors flex items-center justify-center opacity-0 hover:opacity-100">
|
||||||
|
<div className="p-2 bg-white/20 rounded-full backdrop-blur-sm">
|
||||||
|
<Eye size={16} className="text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* More cameras indicator */}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/cameras')}
|
||||||
|
className="w-full py-2 text-center text-xs text-gray-500 dark:text-zinc-500 hover:text-emerald-600 dark:hover:text-emerald-400 border border-dashed border-gray-200 dark:border-zinc-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
+{remainingCount} more camera{remainingCount > 1 ? 's' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Video Modal */}
|
||||||
|
{showModal && selectedCamera && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="relative w-full max-w-4xl mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">{selectedCamera.name}</h2>
|
||||||
|
{selectedCamera.location && (
|
||||||
|
<p className="text-sm text-gray-400">{selectedCamera.location}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Player */}
|
||||||
|
<VideoPlayer
|
||||||
|
streamKey={selectedCamera.streamKey}
|
||||||
|
className="aspect-video"
|
||||||
|
showControls
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Camera Info */}
|
||||||
|
<div className="mt-3 flex items-center justify-between text-sm text-gray-400">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{selectedCamera.manufacturer && (
|
||||||
|
<span>{selectedCamera.manufacturer}</span>
|
||||||
|
)}
|
||||||
|
{selectedCamera.room && (
|
||||||
|
<span>{selectedCamera.room.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs">Stream: {selectedCamera.streamKey}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CameraWidget;
|
||||||
300
frontend/src/components/cameras/VideoPlayer.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Video, VideoOff, Volume2, VolumeX, Maximize2, RefreshCw, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
streamKey: string;
|
||||||
|
className?: string;
|
||||||
|
showControls?: boolean;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected' | 'error') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebRTC video player for go2rtc streams
|
||||||
|
* Connects to go2rtc's WebSocket API for low-latency video playback
|
||||||
|
*/
|
||||||
|
export function VideoPlayer({
|
||||||
|
streamKey,
|
||||||
|
className,
|
||||||
|
showControls = true,
|
||||||
|
autoPlay = true,
|
||||||
|
muted = true,
|
||||||
|
onError,
|
||||||
|
onStatusChange
|
||||||
|
}: VideoPlayerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected');
|
||||||
|
const [isMuted, setIsMuted] = useState(muted);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [reconnectCount, setReconnectCount] = useState(0);
|
||||||
|
|
||||||
|
// go2rtc base URL - uses relative path since it's behind the same domain via Traefik
|
||||||
|
const go2rtcBase = '/monitor';
|
||||||
|
|
||||||
|
const updateStatus = useCallback((newStatus: typeof status) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
onStatusChange?.(newStatus);
|
||||||
|
}, [onStatusChange]);
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
if (pcRef.current) {
|
||||||
|
pcRef.current.close();
|
||||||
|
pcRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
cleanup();
|
||||||
|
updateStatus('connecting');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create RTCPeerConnection
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
|
iceServers: [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
pcRef.current = pc;
|
||||||
|
|
||||||
|
// Handle incoming tracks
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
if (videoRef.current && event.streams[0]) {
|
||||||
|
videoRef.current.srcObject = event.streams[0];
|
||||||
|
updateStatus('connected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
|
||||||
|
updateStatus('disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open WebSocket to go2rtc
|
||||||
|
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${go2rtcBase}/api/ws?src=${encodeURIComponent(streamKey)}`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = async () => {
|
||||||
|
// Add transceivers for receiving video/audio
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
// Create and send offer
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'webrtc/offer',
|
||||||
|
value: pc.localDescription?.sdp
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (msg.type === 'webrtc/answer') {
|
||||||
|
await pc.setRemoteDescription({
|
||||||
|
type: 'answer',
|
||||||
|
sdp: msg.value
|
||||||
|
});
|
||||||
|
} else if (msg.type === 'webrtc/candidate') {
|
||||||
|
if (msg.value) {
|
||||||
|
await pc.addIceCandidate({
|
||||||
|
candidate: msg.value,
|
||||||
|
sdpMid: '0'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
updateStatus('error');
|
||||||
|
onError?.(new Error('WebSocket connection failed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (status !== 'error') {
|
||||||
|
updateStatus('disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send ICE candidates
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'webrtc/candidate',
|
||||||
|
value: event.candidate.candidate
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect:', error);
|
||||||
|
updateStatus('error');
|
||||||
|
onError?.(error as Error);
|
||||||
|
}
|
||||||
|
}, [streamKey, cleanup, updateStatus, onError, status]);
|
||||||
|
|
||||||
|
// Initial connection
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoPlay) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
return cleanup;
|
||||||
|
}, [streamKey, autoPlay, connect, cleanup]);
|
||||||
|
|
||||||
|
// Reconnect logic
|
||||||
|
const handleReconnect = useCallback(() => {
|
||||||
|
setReconnectCount(c => c + 1);
|
||||||
|
connect();
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.muted = !videoRef.current.muted;
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
}
|
||||||
|
}, [isMuted]);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
videoRef.current.requestFullscreen();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"relative overflow-hidden rounded-xl bg-black",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{/* Video Element */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted={isMuted}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status Overlay */}
|
||||||
|
{status !== 'connected' && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||||
|
<div className="text-center">
|
||||||
|
{status === 'connecting' && (
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-8 h-8 text-emerald-400 mx-auto mb-2" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{status === 'disconnected' && (
|
||||||
|
<WifiOff className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<VideoOff className="w-8 h-8 text-red-400 mx-auto mb-2" />
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-400 capitalize">{status}</p>
|
||||||
|
{(status === 'disconnected' || status === 'error') && (
|
||||||
|
<button
|
||||||
|
onClick={handleReconnect}
|
||||||
|
className="mt-3 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls Overlay */}
|
||||||
|
{showControls && status === 'connected' && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||||
|
<Wifi size={12} />
|
||||||
|
<span className="font-medium">LIVE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isMuted ? (
|
||||||
|
<VolumeX size={18} className="text-white" />
|
||||||
|
) : (
|
||||||
|
<Volume2 size={18} className="text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Maximize2 size={18} className="text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live indicator */}
|
||||||
|
{status === 'connected' && (
|
||||||
|
<div className="absolute top-3 left-3 flex items-center gap-1.5 px-2 py-1 bg-red-600 rounded text-xs text-white font-bold">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||||
|
LIVE
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback MSE player for browsers without WebRTC support
|
||||||
|
*/
|
||||||
|
export function VideoPlayerMSE({
|
||||||
|
streamKey,
|
||||||
|
className,
|
||||||
|
showControls = true
|
||||||
|
}: Omit<VideoPlayerProps, 'autoPlay' | 'muted' | 'onError' | 'onStatusChange'>) {
|
||||||
|
const go2rtcBase = '/monitor';
|
||||||
|
const mseUrl = `${go2rtcBase}/api/stream.mp4?src=${encodeURIComponent(streamKey)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"relative overflow-hidden rounded-xl bg-black",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<video
|
||||||
|
src={mseUrl}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
controls={showControls}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoPlayer;
|
||||||