feat: Linear-inspired UI redesign with Space Grotesk headlines
- Complete UI refactor with charcoal/bone color palette - Add Space Grotesk font for headlines, Inter for body - Update all 24+ pages with new design system - Add LinearPrimitives reusable components - Improve dark mode support throughout - Add subtle micro-animations and transitions
This commit is contained in:
parent
11e3fc9de8
commit
71e58dd4c7
36 changed files with 4281 additions and 2670 deletions
|
|
@ -6,35 +6,38 @@
|
|||
|
||||
## Key Achievements
|
||||
|
||||
1. **Layout Designer Enhancements**
|
||||
* **Property Creation:** Implemented "Add Property" workflow directly within the Layout Designer.
|
||||
* **Building Creation:** Added `AddBuildingModal` and "Add New Building" button in the floor selector.
|
||||
* **Floor Creation:** Integrated `AddFloorModal` for creating new floors on the fly.
|
||||
* **API Updates:** Fixed `layout.routes.ts` to support complex nested queries and proper address generation.
|
||||
1. **Nutrient Management Protocol Update**
|
||||
* **Protocol:** Implemented "Test Stock Protocol" (16-gallon scale).
|
||||
|
||||
2. **Backend & Database Fixes**
|
||||
* **Schema Update:** Added bi-directional relation between `Batch` and `FacilityPlant` in `schema.prisma`.
|
||||
* **Seed Script Fixed:** Corrected `seed.js` to use valid `RoleEnum` values (`GROWER`, `STAFF`) and `SupplyCategory` (`OTHER`), and removed invalid `createdById` field in Task creation.
|
||||
* **Type Safety:** Resolved TypeScript errors in `audit.routes.ts` (null vs undefined), `insights.routes.ts` (Batch relations), and `messaging.routes.ts` (duplicate keys).
|
||||
2. **Facility Monitoring (Phase 1)**
|
||||
* **Status:** Infrastructure Deployed (`go2rtc`), Integration Roadmapped.
|
||||
|
||||
3. **Deployment Success**
|
||||
* Successfully deployed the latest code to `nexus-vector`.
|
||||
* Database migrations applied and seed data populated successfully.
|
||||
* Verified frontend application loads and Layout Designer components are active.
|
||||
3. **Layout Designer Enhancements (Multi-Level)**
|
||||
* **Tiers Support:** Can now model multi-level racks (e.g., 2-tier veg racks).
|
||||
|
||||
4. **Visitor Management (Phase 8)**
|
||||
* **Spec:** Created `008_visitor_management.md` detailing the "Digital Badge" workflow.
|
||||
* **Feature:** Implemented Digital Badge system.
|
||||
* **Kiosk:** Displays QR Code upon successful check-in.
|
||||
* **Badge Page:** Publicly accessible page (`/badges/:id`) showing visitor photo, status (Active/Expired), and zones.
|
||||
* **Backend:** Updated `visitors` API to return `visitId` for secure token generation.
|
||||
* **Panopticon (Admin View):**
|
||||
* **Dashboard:** Real-time list of active visitors ("Live View").
|
||||
* **Actions:** Added "Revoke Access" capability with reason logging.
|
||||
* **Status:** Backend schema updated to support `REVOKED` status.
|
||||
|
||||
5. **Roadmap Updates**
|
||||
* **Phase 15 (3D Visualization):** Added to roadmap to fulfill "Figma-meets-SketchUp" vision.
|
||||
|
||||
## Verification Steps Performed
|
||||
|
||||
* **Build:** Backend and Frontend Docker builds completed successfully.
|
||||
* **Database:** `prisma db push` and `node prisma/seed.js` executed without errors.
|
||||
* **Frontend:** Verified access to Layout Designer and presence of "Setup Property" / creation controls.
|
||||
* **Build:** Confirmed backend and frontend builds (including new `qrcode.react` dependency).
|
||||
* **Workflow:**
|
||||
1. Open Kiosk -> Check In Visitor.
|
||||
2. Verify QR Code appears.
|
||||
3. Scan/Click QR Code -> Validates redirect to `/badges/:visitId`.
|
||||
4. Verify Badge Page shows "ACTIVE VISITOR" with pulsing green indicator.
|
||||
|
||||
## Usage Instructions
|
||||
## Next Steps
|
||||
|
||||
To test the new features:
|
||||
|
||||
1. Login as `admin@runfoo.run` / `password123`.
|
||||
2. Navigate to **Layout Designer**.
|
||||
3. Open the top-left dropdown (Floor Selector).
|
||||
4. Use **"Add New Property"** to set up a facility.
|
||||
5. Use **"Add New Building"** to add structures.
|
||||
6. Use **"Add Floor"** to define levels.
|
||||
* **Panopticon View:** Implement the admin dashboard for real-time visitor tracking (Sprint 2 of Phase 8).
|
||||
|
|
|
|||
40
PLANNING_NEXT.md
Normal file
40
PLANNING_NEXT.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Plan of Action: Facility Monitoring & Advanced Treatments
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Date:** 2025-12-11
|
||||
|
||||
Guided by "Spec Kit" principles, we will proceed in the following order. This ensures requirements are clear before implementation begins.
|
||||
|
||||
## 1. Compliance & Security (Priority: High)
|
||||
|
||||
**Objective:** Specification for CCTV & Monitoring Integration.
|
||||
**Why:** Integration with physical security systems (CCTV) requires careful architectural planning regarding bandwidth, security, and protocol support (RTSP, WebRTC, ONVIF) before any code is written.
|
||||
|
||||
- [ ] **Create Spec Document:** `docs/specs/013_facility_monitoring.md`
|
||||
- **Scope:** Define integration points for CCTV streams.
|
||||
- **Architecture:** Decide on direct stream vs. proxy (e.g., go2rtc).
|
||||
- **Security:** Define access control for video feeds.
|
||||
- **Hardware Support:** List supported standard protocols (ONVIF, RTSP).
|
||||
|
||||
## 2. Data Simulation (Priority: Medium)
|
||||
|
||||
**Objective:** Enhanced Demo Data for "Treatments".
|
||||
**Why:** To visualize the "Treatment" workflow (IPM/Nutrients) effectively in the UI, we need rich, realistic seed data.
|
||||
|
||||
- [ ] **Update Spec:** Add "Treatment Workflows" section to `docs/specs/006_cultivation_management.md`.
|
||||
- [ ] **Implementation - Seed Data:**
|
||||
- Update `backend/prisma/seed.js` to include:
|
||||
- Diverse IPM products (Preventative vs Curative).
|
||||
- Complex nutrient recipes.
|
||||
- Historical treatment logs (to show "completed" vs "scheduled" states).
|
||||
|
||||
## 3. Order of Operations
|
||||
|
||||
1. **Finish Current Deployment:** Ensure `Ceiling Height` features are stable on `nexus-vector`.
|
||||
2. **Draft Specs:** Write `docs/specs/013_facility_monitoring.md`.
|
||||
3. **Refine Data Model:** Review if `Treatment` needs expansion based on spec.
|
||||
4. **Implement Seed Data:** Code the realistic treatment scenarios.
|
||||
5. **Review:** Present Spec & Data for approval.
|
||||
|
||||
---
|
||||
*Awaiting completion of current deployment task (Layout Designer Update) before commencing Step 1.*
|
||||
245
docs/AUDIT-AUTH-AND-UI.md
Normal file
245
docs/AUDIT-AUTH-AND-UI.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# Spec Compliance Audit - Authentication & Core UI
|
||||
|
||||
**Date**: 2025-12-12
|
||||
**Auditor**: Antigravity AI
|
||||
**Subject**: Authentication, RBAC, and Core UI Components
|
||||
**Status**: ✅ **IMPLEMENTED (~85%) - Minor Gaps Identified**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Executive Summary
|
||||
|
||||
The Authentication and Core UI systems are **substantially complete**. The implementation follows the architecture spec and includes:
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| **Password Hashing** | ✅ Complete | bcrypt with salt rounds |
|
||||
| **JWT Tokens** | ✅ Complete | Access (24h) + Refresh (7d) |
|
||||
| **Auth Controller** | ✅ Complete | Login, Refresh, Logout, Me |
|
||||
| **Route Protection** | ✅ Complete | `jwtVerify` on all routes |
|
||||
| **Frontend AuthContext** | ✅ Complete | Login, logout, isLoading |
|
||||
| **API Interceptors** | ✅ Complete | Token refresh, retry logic |
|
||||
| **Login Page** | ✅ Complete | Mobile-first, touch-friendly |
|
||||
| **Splash Screen** | ✅ Complete | Branded loading animation |
|
||||
|
||||
**Recommendation**: Address minor gaps (RBAC granularity, Redis refresh tokens).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Compliance Matrix
|
||||
|
||||
| Requirement | Spec Reference | Status | Evidence |
|
||||
|-------------|----------------|--------|----------|
|
||||
| Password hashing (bcrypt) | SPRINT-2-AUTH.md Task 2.1 | ✅ | `backend/src/utils/password.ts` |
|
||||
| JWT access tokens | SPRINT-2-AUTH.md Task 2.2 | ✅ | `backend/src/utils/jwt.ts` (24h expiry) |
|
||||
| JWT refresh tokens | SPRINT-2-AUTH.md Task 2.2 | ✅ | `generateRefreshToken` (7d expiry) |
|
||||
| Auth middleware | SPRINT-2-AUTH.md Task 2.3 | ✅ | `jwtVerify` hook in all routes |
|
||||
| Token refresh endpoint | SPRINT-2-AUTH.md Task 2.6 | ✅ | `POST /api/auth/refresh` |
|
||||
| Logout endpoint | SPRINT-2-AUTH.md Task 2.7 | ⚠️ Partial | Exists but no Redis invalidation |
|
||||
| Frontend token storage | SPRINT-2-AUTH.md Task 2.8 | ✅ | localStorage in AuthContext |
|
||||
| API interceptors | SPRINT-2-AUTH.md Task 2.9 | ✅ | `frontend/src/lib/api.ts` |
|
||||
| RBAC middleware | SPRINT-2-AUTH.md Task 2.4 | ⚠️ Partial | Role is in token, but no route-level enforcement |
|
||||
| Redis refresh token store | SPRINT-2-AUTH.md Task 2.2 | ❌ TODO | Marked as TODO in controller |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Backend Authentication
|
||||
|
||||
### Password Hashing
|
||||
|
||||
**Location**: `backend/src/utils/password.ts`
|
||||
|
||||
```typescript
|
||||
import bcrypt from 'bcrypt';
|
||||
const SALT_ROUNDS = 10;
|
||||
export function hashPassword(password: string) { return bcrypt.hash(password, SALT_ROUNDS); }
|
||||
export function comparePassword(password: string, hash: string) { return bcrypt.compare(password, hash); }
|
||||
```
|
||||
|
||||
**Status**: ✅ **Compliant**
|
||||
|
||||
- Uses bcrypt with recommended salt rounds (10)
|
||||
- Passwords hashed in seed script
|
||||
|
||||
### JWT Token Generation
|
||||
|
||||
**Location**: `backend/src/utils/jwt.ts`
|
||||
|
||||
- **Access Token**: 24 hours (spec says 15 min - see Minor Gap #1)
|
||||
- **Refresh Token**: 7 days ✅
|
||||
- **Payload**: `{ userId, email, role }`
|
||||
|
||||
**Status**: ⚠️ **Minor Gap** - Access token is 24h, spec recommends 15 min for security.
|
||||
|
||||
### Route Protection
|
||||
|
||||
**Pattern**: All routes use `jwtVerify` hook
|
||||
|
||||
```typescript
|
||||
fastify.addHook('onRequest', async (request) => {
|
||||
await request.jwtVerify();
|
||||
});
|
||||
```
|
||||
|
||||
**Routes Protected**: (19 route files confirmed)
|
||||
|
||||
- `batches.routes.ts` ✅
|
||||
- `layout.routes.ts` ✅
|
||||
- `walkthrough.routes.ts` ✅
|
||||
- `visitors.routes.ts` ✅
|
||||
- `documents.routes.ts` ✅
|
||||
- `financial.routes.ts` ✅
|
||||
- `environment.routes.ts` ✅
|
||||
- ... (all routes)
|
||||
|
||||
**Status**: ✅ **Compliant**
|
||||
|
||||
---
|
||||
|
||||
## 📱 Frontend Authentication
|
||||
|
||||
### AuthContext
|
||||
|
||||
**Location**: `frontend/src/context/AuthContext.tsx`
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ `login(token, user)` - stores token and user
|
||||
- ✅ `logout()` - clears token and user
|
||||
- ✅ `isLoading` - initialization state
|
||||
- ✅ Auto-fetch `/auth/me` on mount if token exists
|
||||
|
||||
**Status**: ✅ **Compliant**
|
||||
|
||||
### API Client
|
||||
|
||||
**Location**: `frontend/src/lib/api.ts`
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ Authorization header injection
|
||||
- ✅ Token refresh on 401
|
||||
- ✅ Request queuing during refresh
|
||||
- ✅ Exponential backoff retry (1s, 2s, 4s)
|
||||
- ✅ Rate limit handling (429)
|
||||
- ✅ 30s timeout
|
||||
|
||||
**Status**: ✅ **Compliant** - Excellent implementation!
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Core UI Components
|
||||
|
||||
### Login Page
|
||||
|
||||
**Location**: `frontend/src/pages/LoginPage.tsx`
|
||||
|
||||
**Compliance with UI/UX Spec**:
|
||||
|
||||
- ✅ 777 Wolfpack branding (logo, "CA GROW OPS")
|
||||
- ✅ Mobile-first layout (`max-w-md md:max-w-lg`)
|
||||
- ✅ Touch-friendly inputs (`py-3 md:py-4`)
|
||||
- ✅ Large submit button (`min-h-[56px]`)
|
||||
- ✅ Dark mode gradient background
|
||||
- ✅ Error handling with styled alert
|
||||
- ✅ Loading state ("Accessing...")
|
||||
- ✅ DevTools for quick login (dev only)
|
||||
|
||||
**Status**: ✅ **Excellent** - Fully matches spec aesthetic
|
||||
|
||||
### Splash Screen
|
||||
|
||||
**Location**: `frontend/src/components/SplashScreen.tsx`
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ Branded logo with pulsing animation
|
||||
- ✅ "CA Grow Ops Manager" title
|
||||
- ✅ Loading dots animation
|
||||
- ✅ Configurable duration
|
||||
|
||||
**Status**: ✅ **Compliant**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Gaps Identified
|
||||
|
||||
### 1. Access Token Expiry (Minor)
|
||||
|
||||
**Spec Says**: 15 minutes
|
||||
**Current**: 24 hours
|
||||
**Risk**: Lower security - longer window if token is compromised
|
||||
**Recommendation**: Consider reducing to 1 hour as a balance
|
||||
|
||||
### 2. Redis Refresh Token Storage (Moderate)
|
||||
|
||||
**Spec Says**: Store refresh tokens in Redis for invalidation
|
||||
**Current**: TODO in code - tokens not stored/invalidated
|
||||
**Risk**: Cannot revoke refresh tokens (e.g., on password change, logout)
|
||||
**Recommendation**: Implement when Redis is available in infrastructure
|
||||
|
||||
### 3. RBAC Route Enforcement (Moderate)
|
||||
|
||||
**Spec Says**: `authorize(...roles)` middleware per route
|
||||
**Current**: Role is in JWT payload but not enforced at route level
|
||||
**Risk**: Any authenticated user can access any endpoint
|
||||
**Recommendation**: Create `requireRole(role: string[])` middleware
|
||||
|
||||
### 4. httpOnly Cookies (Future Enhancement)
|
||||
|
||||
**Architecture Says**: "Frontend stores tokens in httpOnly cookies (access)"
|
||||
**Current**: localStorage for both tokens
|
||||
**Risk**: XSS vulnerability can steal tokens
|
||||
**Recommendation**: Future enhancement when backend supports cookie-based auth
|
||||
|
||||
---
|
||||
|
||||
## ✅ Action Items
|
||||
|
||||
### Priority 1: Security (Recommended Now)
|
||||
|
||||
- [ ] Add `requireRole` middleware for sensitive routes
|
||||
- [ ] Reduce access token expiry to 1-4 hours
|
||||
|
||||
### Priority 2: Infrastructure (When Available)
|
||||
|
||||
- [ ] Implement Redis refresh token storage
|
||||
- [ ] Add refresh token revocation on password change
|
||||
|
||||
### Priority 3: Future Enhancement
|
||||
|
||||
- [ ] Migrate to httpOnly cookies for token storage
|
||||
- [ ] Add CSRF protection
|
||||
|
||||
---
|
||||
|
||||
## 📝 Audit Conclusion
|
||||
|
||||
**Status**: ✅ **IMPLEMENTED** (~85% Complete)
|
||||
|
||||
The authentication system is **production-ready** with the following caveats:
|
||||
|
||||
1. RBAC is identity-based (any logged-in user can access any route) - fine for small teams
|
||||
2. Refresh tokens are not revocable (logout doesn't truly invalidate)
|
||||
3. Tokens stored in localStorage (standard practice, minor XSS risk)
|
||||
|
||||
The **Core UI** (Login, Splash) is **fully compliant** with the "Premium" aesthetic spec - uses correct colors, typography, mobile-first design, and 777 Wolfpack branding.
|
||||
|
||||
**No blocking issues** - proceed with confidence!
|
||||
|
||||
---
|
||||
|
||||
## 📋 Files Audited
|
||||
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| `backend/src/utils/password.ts` | ✅ Reviewed |
|
||||
| `backend/src/utils/jwt.ts` | ✅ Reviewed |
|
||||
| `backend/src/controllers/auth.controller.ts` | ✅ Reviewed |
|
||||
| `backend/src/routes/*.routes.ts` | ✅ All use jwtVerify |
|
||||
| `frontend/src/context/AuthContext.tsx` | ✅ Reviewed |
|
||||
| `frontend/src/lib/api.ts` | ✅ Reviewed |
|
||||
| `frontend/src/pages/LoginPage.tsx` | ✅ Reviewed |
|
||||
| `frontend/src/components/SplashScreen.tsx` | ✅ Reviewed |
|
||||
| `docs/SPRINT-2-AUTH.md` | ✅ Reference Spec |
|
||||
| `docs/architecture.md` | ✅ Reference Spec |
|
||||
186
docs/AUDIT-LAYOUT-DESIGNER-SPEC.md
Normal file
186
docs/AUDIT-LAYOUT-DESIGNER-SPEC.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# Spec Compliance Audit - Layout Designer & UI/UX Refactor
|
||||
|
||||
**Date**: 2025-12-12 (Revised)
|
||||
**Auditor**: Antigravity AI
|
||||
**Subject**: Facility Layout Designer (Spec Kit Compliance)
|
||||
**Status**: ✅ **IMPLEMENTED (~80%) - Ready for UI/UX Polish**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Executive Summary
|
||||
|
||||
**Great news!** The Layout Designer feature is **already substantially built**. The foundational work covers:
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| **Database Schema** | ✅ Complete | `backend/prisma/schema.prisma` (Lines 501-641) |
|
||||
| **API Routes** | ✅ Complete | `backend/src/routes/layout.routes.ts` (764 lines) |
|
||||
| **Zustand Store** | ✅ Complete | `frontend/src/stores/layoutStore.ts` (388 lines) |
|
||||
| **API Client** | ✅ Complete | `frontend/src/lib/layoutApi.ts` |
|
||||
| **Main Page** | ✅ Complete | `frontend/src/features/layout-designer/LayoutDesignerPage.tsx` |
|
||||
| **Canvas (Konva)** | ✅ Complete | `frontend/src/features/layout-designer/components/LayoutCanvas.tsx` |
|
||||
| **Inspector Panel** | ✅ Complete | `frontend/src/features/layout-designer/components/InspectorPanel.tsx` |
|
||||
| **Sub-Components** | ✅ Complete | 10 components in `components/` directory |
|
||||
|
||||
**Recommendation**: Proceed directly to **UI/UX Polish Sprint**.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current Implementation Status
|
||||
|
||||
### Database Schema (Prisma)
|
||||
|
||||
The following models are **already in production**:
|
||||
|
||||
- `FacilityProperty` - Top-level (License)
|
||||
- `FacilityBuilding` - Sub-property
|
||||
- `FacilityFloor` - Per-building
|
||||
- `FacilityRoom` - Per-floor (canvas shapes)
|
||||
- `FacilitySection` - Grow tables/racks within rooms
|
||||
- `FacilityPosition` - Individual plant slots (row/column/tier)
|
||||
- `FacilityPlant` - Individual plants with METRC tags
|
||||
- `PlantLocationHistory` - Move audit trail
|
||||
|
||||
### API Endpoints
|
||||
|
||||
All CRUD operations are **fully implemented**:
|
||||
|
||||
| Endpoint | Method | Function |
|
||||
|----------|--------|----------|
|
||||
| `/api/layout/properties` | GET/POST | Property CRUD |
|
||||
| `/api/layout/buildings` | POST | Building CRUD |
|
||||
| `/api/layout/floors/:id` | GET | Floor with rooms |
|
||||
| `/api/layout/floors` | POST | Create floor |
|
||||
| `/api/layout/rooms` | POST | Create room |
|
||||
| `/api/layout/rooms/:id` | PUT/DELETE | Room CRUD |
|
||||
| `/api/layout/rooms/:id/sections` | GET | Get sections with positions |
|
||||
| `/api/layout/sections` | POST | Create section with positions |
|
||||
| `/api/layout/sections/:id` | GET | Get section with plants |
|
||||
| `/api/layout/sections/:id/fill` | POST | Bulk plant placement |
|
||||
| `/api/layout/positions/:id/occupy` | POST | Place single plant |
|
||||
| `/api/layout/plants/:id/move` | POST | Move plant with history |
|
||||
| `/api/layout/floors/:id/layout` | POST | **Bulk save layout** |
|
||||
| `/api/layout/positions/:id/address` | GET | Generate METRC address |
|
||||
|
||||
### Frontend Components
|
||||
|
||||
The Figma-like editor is **already functional**:
|
||||
|
||||
```text
|
||||
features/layout-designer/
|
||||
├── LayoutDesignerPage.tsx (565 lines - main page)
|
||||
└── components/
|
||||
├── LayoutCanvas.tsx (Canvas with react-konva)
|
||||
├── InspectorPanel.tsx (Room/Section/Position details)
|
||||
├── LayersPanel.tsx (Room list)
|
||||
├── ElementsPanel.tsx (Drag-to-add elements)
|
||||
├── FloorSelector.tsx (Building/Floor picker)
|
||||
├── PropertySetup.tsx (Initial property creation)
|
||||
├── AddBuildingModal.tsx
|
||||
├── AddFloorModal.tsx
|
||||
├── AddSectionDialog.tsx
|
||||
└── PlantInventory.tsx (Plant list sidebar)
|
||||
```
|
||||
|
||||
### State Management (Zustand)
|
||||
|
||||
The store (`layoutStore.ts`) implements all required functionality:
|
||||
|
||||
- Canvas state (zoom, pan, grid, snap)
|
||||
- Selection state (rooms, sections, positions)
|
||||
- Room/Section CRUD with undo/redo history
|
||||
- Data normalization (rooms and sections stored separately)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Remaining Gaps for UI/UX Polish
|
||||
|
||||
### 1. Visual Design Alignment
|
||||
|
||||
**Issue**: The current UI uses basic Slate/Emerald but lacks the "Premium" polish defined in `LAYOUT-DESIGNER-UX.md`.
|
||||
|
||||
**Missing**:
|
||||
|
||||
- Glassmorphism panels (`backdrop-blur`, subtle borders)
|
||||
- Micro-animations (selection glow, drag feedback)
|
||||
- Premium tooltips
|
||||
- Keyboard shortcut hints
|
||||
|
||||
### 2. Mobile/Tablet Experience
|
||||
|
||||
**Issue**: The Layout Designer is desktop-only. The spec defines mobile gestures, but they are not implemented.
|
||||
|
||||
**Missing**:
|
||||
|
||||
- Touch gestures (pinch-to-zoom, two-finger pan)
|
||||
- Mobile bottom sheet for inspector
|
||||
- "View Only" mode for small screens
|
||||
|
||||
### 3. QR Code / Scanning Integration
|
||||
|
||||
**Issue**: The mobile flow ("Scan Plant Tag -> Show Location") is not implemented.
|
||||
|
||||
**Missing**:
|
||||
|
||||
- `@zxing/browser` integration
|
||||
- Position QR code generation (`qrcode.react`)
|
||||
- Scan-to-locate flow
|
||||
|
||||
### 4. Export Functionality
|
||||
|
||||
**Issue**: The "Export" button exists but is non-functional.
|
||||
|
||||
**Missing**:
|
||||
|
||||
- PDF floor plan export
|
||||
- PNG/SVG image export
|
||||
- METRC location report export
|
||||
|
||||
---
|
||||
|
||||
## ✅ UI/UX Refactor Sprint Plan
|
||||
|
||||
### Sprint 1: Visual Polish (4-6 hours)
|
||||
|
||||
- [ ] Apply glassmorphism to sidebar panels
|
||||
- [ ] Add selection glow animation (`box-shadow` with emerald color)
|
||||
- [ ] Improve room/section hover states
|
||||
- [ ] Add micro-animation on room placement
|
||||
- [ ] Add keyboard shortcut hints in tooltips
|
||||
|
||||
### Sprint 2: Inspector & Interaction Polish (4-6 hours)
|
||||
|
||||
- [ ] Redesign `InspectorPanel` for better hierarchy (Room > Section > Position)
|
||||
- [ ] Add position grid preview with color-coded status
|
||||
- [ ] Implement position click to select (currently working, needs visual feedback)
|
||||
- [ ] Add "Bulk Actions" for selected positions (Mark Harvested, Mark Issue)
|
||||
|
||||
### Sprint 3: Mobile Experience (6-8 hours)
|
||||
|
||||
- [ ] Add responsive breakpoints (hide sidebars on mobile)
|
||||
- [ ] Implement touch gestures (pinch/pan)
|
||||
- [ ] Create mobile "Position Detail" bottom sheet
|
||||
- [ ] Add "View Only" mode that disables editing
|
||||
|
||||
### Sprint 4: QR & Export (4-6 hours)
|
||||
|
||||
- [ ] Integrate QR code generation for positions
|
||||
- [ ] Implement position scan-to-locate
|
||||
- [ ] Add PDF export using `html2canvas` + `jspdf`
|
||||
- [ ] Add JSON export/import for layout backup
|
||||
|
||||
---
|
||||
|
||||
## 📝 Audit Conclusion
|
||||
|
||||
**Status**: ✅ **IMPLEMENTED** (Proceed to Polish Sprint)
|
||||
|
||||
The Layout Designer is **already 80% complete**. The schema, API, store, and core UI are all functional. The remaining work is purely **UI/UX polish** to align with the premium aesthetic defined in the spec.
|
||||
|
||||
**Next Steps**:
|
||||
|
||||
1. Review the current `/layout-designer` page in the browser
|
||||
2. Identify specific visual improvements needed
|
||||
3. Begin Sprint 1: Visual Polish
|
||||
|
||||
**No schema migration required** – the data model is already complete and matches the spec.
|
||||
368
docs/DESIGN-SYSTEM-LINEAR.md
Normal file
368
docs/DESIGN-SYSTEM-LINEAR.md
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
# Linear-Inspired Design System
|
||||
|
||||
**Version**: 2.0.0
|
||||
**Date**: 2025-12-12
|
||||
**Status**: 🚀 Implementing
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Design Philosophy
|
||||
|
||||
Inspired by Linear, our design prioritizes:
|
||||
|
||||
1. **Speed Over Spectacle** - UI should feel instant
|
||||
2. **Keyboard-First** - Power users can navigate without a mouse
|
||||
3. **Clarity Over Decoration** - Every element serves a purpose
|
||||
4. **Minimalist Density** - More information, less noise
|
||||
5. **Subtle Motion** - Micro-animations guide, never distract
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Color Palette
|
||||
|
||||
### Core Colors (Charcoal & Bone)
|
||||
|
||||
```css
|
||||
/* Light Mode ("Bone") */
|
||||
--color-bg-primary: #FFFFFF; /* Pure white */
|
||||
--color-bg-secondary: #FAFAFA; /* Off-white */
|
||||
--color-bg-tertiary: #F5F5F5; /* Light gray */
|
||||
--color-bg-elevated: #FFFFFF; /* Cards, modals */
|
||||
|
||||
--color-text-primary: #171717; /* Near black */
|
||||
--color-text-secondary: #525252; /* Dark gray */
|
||||
--color-text-tertiary: #A3A3A3; /* Mid gray */
|
||||
--color-text-quaternary: #D4D4D4; /* Light gray (disabled) */
|
||||
|
||||
--color-border-default: #E5E5E5; /* Subtle border */
|
||||
--color-border-subtle: #F5F5F5; /* Very subtle */
|
||||
--color-border-strong: #D4D4D4; /* Emphasis */
|
||||
|
||||
/* Dark Mode ("Charcoal") */
|
||||
--color-bg-primary: #0A0A0A; /* Near black */
|
||||
--color-bg-secondary: #171717; /* Charcoal */
|
||||
--color-bg-tertiary: #262626; /* Dark gray */
|
||||
--color-bg-elevated: #1F1F1F; /* Cards, modals */
|
||||
|
||||
--color-text-primary: #FAFAFA; /* Off-white */
|
||||
--color-text-secondary: #A3A3A3; /* Mid gray */
|
||||
--color-text-tertiary: #737373; /* Dark gray */
|
||||
--color-text-quaternary: #525252; /* Darker gray (disabled) */
|
||||
|
||||
--color-border-default: #262626; /* Subtle border */
|
||||
--color-border-subtle: #1F1F1F; /* Very subtle */
|
||||
--color-border-strong: #404040; /* Emphasis */
|
||||
```
|
||||
|
||||
### Accent Colors (Minimal, Purposeful)
|
||||
|
||||
```css
|
||||
/* Accent - Desaturated Blue (Linear-style) */
|
||||
--color-accent: #5E6AD2; /* Primary accent */
|
||||
--color-accent-hover: #6E7AE2; /* Hover state */
|
||||
--color-accent-muted: rgba(94, 106, 210, 0.15);
|
||||
|
||||
/* Status Colors (Desaturated) */
|
||||
--color-success: #22C55E; /* Green - success */
|
||||
--color-success-muted: rgba(34, 197, 94, 0.15);
|
||||
|
||||
--color-warning: #EAB308; /* Yellow - warning */
|
||||
--color-warning-muted: rgba(234, 179, 8, 0.15);
|
||||
|
||||
--color-error: #EF4444; /* Red - error */
|
||||
--color-error-muted: rgba(239, 68, 68, 0.15);
|
||||
|
||||
--color-info: #5E6AD2; /* Same as accent */
|
||||
--color-info-muted: rgba(94, 106, 210, 0.15);
|
||||
```
|
||||
|
||||
### Semantic Mapping
|
||||
|
||||
| Usage | Light Mode | Dark Mode |
|
||||
|-------|------------|-----------|
|
||||
| Page background | `#FFFFFF` | `#0A0A0A` |
|
||||
| Card background | `#FFFFFF` | `#171717` |
|
||||
| Sidebar background | `#FAFAFA` | `#0F0F0F` |
|
||||
| Primary text | `#171717` | `#FAFAFA` |
|
||||
| Secondary text | `#525252` | `#A3A3A3` |
|
||||
| Borders | `#E5E5E5` | `#262626` |
|
||||
| Focus ring | `#5E6AD2` | `#5E6AD2` |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
```css
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
```
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Name | Size | Weight | Line Height | Usage |
|
||||
|------|------|--------|-------------|-------|
|
||||
| `xs` | 11px | 400 | 1.4 | Labels, metadata |
|
||||
| `sm` | 13px | 400 | 1.5 | Secondary text |
|
||||
| `base` | 14px | 400 | 1.6 | Body text |
|
||||
| `lg` | 16px | 500 | 1.5 | Subheadings |
|
||||
| `xl` | 18px | 600 | 1.4 | Headings |
|
||||
| `2xl` | 24px | 600 | 1.3 | Page titles |
|
||||
| `3xl` | 30px | 700 | 1.2 | Hero text |
|
||||
|
||||
### Letter Spacing
|
||||
|
||||
```css
|
||||
--tracking-tight: -0.02em; /* Headings */
|
||||
--tracking-normal: -0.01em; /* Body */
|
||||
--tracking-wide: 0.02em; /* All caps labels */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 Spacing & Layout
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
```css
|
||||
--space-0: 0;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
```
|
||||
|
||||
### Border Radius
|
||||
|
||||
```css
|
||||
--radius-sm: 4px; /* Buttons, inputs */
|
||||
--radius-md: 6px; /* Cards */
|
||||
--radius-lg: 8px; /* Modals */
|
||||
--radius-xl: 12px; /* Large cards */
|
||||
--radius-full: 9999px; /* Pills, avatars */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ Micro-Animations
|
||||
|
||||
### Timing Functions
|
||||
|
||||
```css
|
||||
/* Linear's signature easing */
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
```
|
||||
|
||||
### Animation Durations
|
||||
|
||||
```css
|
||||
--duration-instant: 50ms; /* Color changes */
|
||||
--duration-fast: 100ms; /* Hover states */
|
||||
--duration-normal: 150ms; /* Most transitions */
|
||||
--duration-slow: 250ms; /* Modals, drawers */
|
||||
--duration-slower: 400ms; /* Page transitions */
|
||||
```
|
||||
|
||||
### Standard Transitions
|
||||
|
||||
```css
|
||||
/* Hover state */
|
||||
.interactive {
|
||||
transition:
|
||||
background-color var(--duration-fast) var(--ease-out-expo),
|
||||
color var(--duration-fast) var(--ease-out-expo),
|
||||
border-color var(--duration-fast) var(--ease-out-expo),
|
||||
transform var(--duration-normal) var(--ease-spring);
|
||||
}
|
||||
|
||||
/* Subtle press effect */
|
||||
.interactive:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Focus ring animation */
|
||||
.focus-ring:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--color-accent);
|
||||
transition: box-shadow var(--duration-normal) var(--ease-out-expo);
|
||||
}
|
||||
```
|
||||
|
||||
### Component-Specific Animations
|
||||
|
||||
```css
|
||||
/* Button hover glow */
|
||||
@keyframes button-glow {
|
||||
0% { box-shadow: 0 0 0 0 rgba(94, 106, 210, 0.4); }
|
||||
100% { box-shadow: 0 0 0 4px rgba(94, 106, 210, 0); }
|
||||
}
|
||||
|
||||
/* Card hover lift */
|
||||
@keyframes card-lift {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
/* Fade in */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Slide up */
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Scale in */
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Progress bar shimmer */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Component Patterns
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Base button */
|
||||
.btn {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--duration-fast) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
/* Primary (accent) */
|
||||
.btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Secondary (ghost) */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--duration-fast) var(--ease-out-expo);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--duration-fast) var(--ease-out-expo);
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-muted);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Breakpoints
|
||||
|
||||
```css
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌙 Dark Mode Strategy
|
||||
|
||||
- **Default**: Follow system preference (`prefers-color-scheme`)
|
||||
- **Toggle**: Available in settings
|
||||
- **Persistence**: localStorage key `theme`
|
||||
- **Transition**: Smooth 150ms color transition on toggle
|
||||
|
||||
---
|
||||
|
||||
## ♿ Accessibility
|
||||
|
||||
- **Focus visible**: All interactive elements have visible focus ring
|
||||
- **Contrast**: Minimum 4.5:1 for text, 3:1 for large text/graphics
|
||||
- **Motion**: Respect `prefers-reduced-motion`
|
||||
- **Touch targets**: Minimum 44×44px
|
||||
- **Keyboard**: Full keyboard navigation support
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Implementation Priority
|
||||
|
||||
1. **Phase 1: Colors & Typography** (2-3 hours)
|
||||
- Update `tailwind.config.js` with new color tokens
|
||||
- Update `index.css` with new font imports
|
||||
- Create CSS custom properties
|
||||
|
||||
2. **Phase 2: Core Components** (3-4 hours)
|
||||
- Update Button component
|
||||
- Update Card component
|
||||
- Update Input component
|
||||
- Update focus states
|
||||
|
||||
3. **Phase 3: Layouts** (2-3 hours)
|
||||
- Update Sidebar
|
||||
- Update PageHeader
|
||||
- Update Navigation
|
||||
|
||||
4. **Phase 4: Micro-Animations** (2-3 hours)
|
||||
- Add hover transitions
|
||||
- Add press effects
|
||||
- Add page transitions
|
||||
|
||||
5. **Phase 5: Polish** (2-3 hours)
|
||||
- Dark mode refinement
|
||||
- Accessibility audit
|
||||
- Performance check
|
||||
|
|
@ -17,6 +17,19 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# .well-known directory (serve directly)
|
||||
location /.well-known/ {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
|
||||
# APK download
|
||||
location /visitorkiosk.apk {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type application/vnd.android.package-archive;
|
||||
add_header Content-Disposition "attachment; filename=visitorkiosk.apk";
|
||||
}
|
||||
|
||||
# Frontend SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { Menu, X, Command } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Sidebar } from './layout/Sidebar';
|
||||
import { MobileNav } from './layout/MobileNav';
|
||||
|
|
@ -19,51 +19,54 @@ export default function Layout() {
|
|||
const [mobileSheetOpen, setMobileSheetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-slate-50 dark:bg-slate-900">
|
||||
<div className="flex flex-col h-screen bg-primary">
|
||||
{/* Skip to main content link (accessibility) */}
|
||||
<a href="#main-content" className="skip-to-main">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="absolute left-0 top-0 -translate-y-full bg-accent 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
|
||||
</a>
|
||||
|
||||
{/* Mobile Top Header */}
|
||||
<header className="md:hidden bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between sticky top-0 z-40">
|
||||
<header className="md:hidden bg-elevated border-b border-default px-4 py-3 flex items-center justify-between sticky top-0 z-40">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/assets/logo-777-wolfpack.jpg"
|
||||
alt="777 Wolfpack"
|
||||
className="w-9 h-9 rounded-full ring-2 ring-emerald-500/20"
|
||||
className="w-8 h-8 rounded-lg"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight">
|
||||
777 WOLFPACK
|
||||
<h1 className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
||||
777 Wolfpack
|
||||
</h1>
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400">
|
||||
Grow Ops Manager
|
||||
<p className="text-[10px] text-tertiary">
|
||||
Operations
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
className="p-2 rounded-md hover:bg-tertiary transition-colors duration-fast"
|
||||
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X size={22} className="text-slate-700 dark:text-slate-300" />
|
||||
<X size={20} className="text-primary" />
|
||||
) : (
|
||||
<Menu size={22} className="text-slate-700 dark:text-slate-300" />
|
||||
<Menu size={20} className="text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile Dropdown Menu (top menu when hamburger clicked) */}
|
||||
{/* Mobile Dropdown Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 top-[57px] z-30 bg-black/40"
|
||||
className="md:hidden fixed inset-0 top-[53px] z-30 bg-black/40 animate-fade-in"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-xl max-h-[60vh] overflow-y-auto"
|
||||
className="bg-elevated border-b border-default shadow-xl max-h-[60vh] overflow-y-auto animate-slide-up"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4">
|
||||
|
|
@ -76,50 +79,66 @@ export default function Layout() {
|
|||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside
|
||||
className="hidden md:flex w-64 lg:w-72 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex-col"
|
||||
className="hidden md:flex w-60 lg:w-64 bg-secondary border-r border-default flex-col"
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="p-5 border-b border-slate-200 dark:border-slate-700">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="p-4 border-b border-default">
|
||||
<Link to="/" className="flex items-center gap-3 group">
|
||||
<div className="relative">
|
||||
<img
|
||||
src="/assets/logo-777-wolfpack.jpg"
|
||||
alt="777 Wolfpack"
|
||||
className="w-11 h-11 rounded-full ring-2 ring-emerald-500/20"
|
||||
className="w-9 h-9 rounded-lg transition-transform duration-fast group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-emerald-500 rounded-full border-2 border-white dark:border-slate-800 animate-pulse" />
|
||||
{/* Status indicator */}
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-success rounded-full border-2 border-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-slate-900 dark:text-white leading-tight">
|
||||
777 WOLFPACK
|
||||
<h1 className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
||||
777 Wolfpack
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Grow Ops Manager
|
||||
<p className="text-xs text-tertiary">
|
||||
Operations
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search hint */}
|
||||
<div className="px-4 py-3 border-b border-subtle">
|
||||
<button
|
||||
onClick={() => dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-tertiary bg-tertiary/50 hover:bg-tertiary rounded-md transition-colors duration-fast"
|
||||
>
|
||||
<Command size={14} />
|
||||
<span>Search...</span>
|
||||
<kbd className="ml-auto text-[10px] font-mono bg-primary px-1.5 py-0.5 rounded">⌘K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className="mt-auto">
|
||||
{/* Theme Toggle */}
|
||||
<div className="px-4 py-3 border-t border-default">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
|
||||
<UserMenu />
|
||||
{/* User Menu */}
|
||||
<div className="p-3 border-t border-default">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-1 overflow-auto pb-24 md:pb-8 custom-scrollbar"
|
||||
className="flex-1 overflow-auto pb-20 md:pb-6 custom-scrollbar bg-primary"
|
||||
role="main"
|
||||
>
|
||||
<PageTitleUpdater />
|
||||
|
|
@ -145,7 +164,7 @@ export default function Layout() {
|
|||
{/* Session Timeout Warning */}
|
||||
<SessionTimeoutWarning />
|
||||
|
||||
{/* Dev Tools - only shows in development/testing */}
|
||||
{/* Dev Tools */}
|
||||
<DevTools />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,51 +5,125 @@ interface SplashScreenProps {
|
|||
duration?: number;
|
||||
}
|
||||
|
||||
export function SplashScreen({ onComplete, duration = 2000 }: SplashScreenProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
export function SplashScreen({ onComplete, duration = 1800 }: SplashScreenProps) {
|
||||
const [phase, setPhase] = useState<'logo' | 'text' | 'fadeOut'>('logo');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
// Phase 1: Logo appears (0ms)
|
||||
// Phase 2: Text fades in (600ms)
|
||||
const textTimer = setTimeout(() => setPhase('text'), 600);
|
||||
|
||||
// Phase 3: Fade out starts (duration - 400ms)
|
||||
const fadeTimer = setTimeout(() => setPhase('fadeOut'), duration - 400);
|
||||
|
||||
// Complete
|
||||
const completeTimer = setTimeout(() => {
|
||||
onComplete?.();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => {
|
||||
clearTimeout(textTimer);
|
||||
clearTimeout(fadeTimer);
|
||||
clearTimeout(completeTimer);
|
||||
};
|
||||
}, [duration, onComplete]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 animate-in fade-in">
|
||||
<div className="flex flex-col items-center gap-6 p-8">
|
||||
{/* 777 Wolfpack Logo */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`
|
||||
fixed inset-0 z-50 flex items-center justify-center bg-primary
|
||||
transition-opacity duration-slower ease-out-expo
|
||||
${phase === 'fadeOut' ? 'opacity-0' : 'opacity-100'}
|
||||
`}
|
||||
>
|
||||
{/* Subtle dot pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.02] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={`
|
||||
relative transition-all duration-slow ease-out-expo
|
||||
${phase === 'logo' ? 'scale-95 opacity-0' : 'scale-100 opacity-100'}
|
||||
`}
|
||||
style={{ transitionDelay: '100ms' }}
|
||||
>
|
||||
<img
|
||||
src="/assets/logo-777-wolfpack.jpg"
|
||||
alt="777 Wolfpack"
|
||||
className="w-48 h-48 md:w-64 md:h-64 rounded-full shadow-2xl shadow-blue-500/50 animate-in zoom-in duration-500"
|
||||
className="w-24 h-24 md:w-32 md:h-32 rounded-2xl shadow-xl"
|
||||
/>
|
||||
|
||||
{/* Subtle pulse ring */}
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 rounded-2xl border border-current opacity-0
|
||||
${phase !== 'logo' ? 'animate-ping' : ''}
|
||||
`}
|
||||
style={{
|
||||
animationDuration: '2s',
|
||||
opacity: phase !== 'logo' ? 0.1 : 0,
|
||||
}}
|
||||
/>
|
||||
{/* Pulsing ring effect */}
|
||||
<div className="absolute inset-0 rounded-full border-4 border-blue-400 animate-ping opacity-20" />
|
||||
</div>
|
||||
|
||||
{/* App Title */}
|
||||
<div className="text-center space-y-2 animate-in slide-in-from-bottom duration-700">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white">
|
||||
CA Grow Ops Manager
|
||||
{/* Text */}
|
||||
<div
|
||||
className={`
|
||||
text-center transition-all duration-slow ease-out-expo
|
||||
${phase === 'text' || phase === 'fadeOut'
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 translate-y-2'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-primary tracking-tight">
|
||||
777 Wolfpack
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-blue-200">
|
||||
777 Wolfpack Edition
|
||||
<p className="text-sm text-tertiary mt-1">
|
||||
Grow Operations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
{/* Loading indicator - three dots */}
|
||||
<div
|
||||
className={`
|
||||
flex gap-1.5 transition-opacity duration-normal
|
||||
${phase === 'text' ? 'opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-tertiary"
|
||||
style={{
|
||||
animation: 'pulse-dot 1.4s ease-in-out infinite',
|
||||
animationDelay: `${i * 0.16}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyframes for dot pulse */}
|
||||
<style>{`
|
||||
@keyframes pulse-dot {
|
||||
0%, 80%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { getFilteredNavSections, type NavSection as NavSectionType, type NavItem } from '../../lib/navigation';
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ interface SidebarProps {
|
|||
|
||||
/**
|
||||
* Desktop sidebar navigation with collapsible sections
|
||||
* Linear-inspired design: minimal, clean, subtle animations
|
||||
*/
|
||||
export function Sidebar({ onItemClick }: SidebarProps) {
|
||||
const { role } = usePermissions();
|
||||
|
|
@ -17,7 +18,7 @@ export function Sidebar({ onItemClick }: SidebarProps) {
|
|||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
<nav className="flex-1 px-3 py-3 space-y-4 overflow-y-auto custom-scrollbar">
|
||||
{sections.map(section => (
|
||||
<NavSection
|
||||
key={section.id}
|
||||
|
|
@ -38,26 +39,31 @@ interface NavSectionProps {
|
|||
}
|
||||
|
||||
function NavSection({ section, currentPath, onItemClick, defaultOpen }: NavSectionProps) {
|
||||
// Auto-expand if current path is in this section
|
||||
const hasActiveItem = section.items.some(item => item.path === currentPath);
|
||||
const [isOpen, setIsOpen] = useState(hasActiveItem || defaultOpen !== false);
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div>
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-[11px] font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-[11px] font-medium text-tertiary uppercase tracking-wider hover:text-secondary transition-colors duration-fast"
|
||||
>
|
||||
<span>{section.label}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isOpen ? '' : '-rotate-90'}`}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={`transition-transform duration-fast ${isOpen ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span>{section.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Section Items */}
|
||||
<div className={`space-y-0.5 overflow-hidden transition-all duration-200 ${isOpen ? 'max-h-96' : 'max-h-0'}`}>
|
||||
<div
|
||||
className={`
|
||||
mt-1 space-y-0.5 overflow-hidden
|
||||
transition-all duration-normal ease-out-expo
|
||||
${isOpen ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}
|
||||
`}
|
||||
>
|
||||
{section.items.map(item => (
|
||||
<NavItemLink
|
||||
key={item.id}
|
||||
|
|
@ -84,21 +90,29 @@ function NavItemLink({ item, isActive, onClick }: NavItemLinkProps) {
|
|||
<Link
|
||||
to={item.path}
|
||||
onClick={onClick}
|
||||
className={`group flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all ${isActive
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 font-medium'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
className={`
|
||||
group flex items-center gap-3 px-3 py-2 rounded-md
|
||||
transition-all duration-fast
|
||||
${isActive
|
||||
? 'bg-tertiary text-primary font-medium'
|
||||
: 'text-secondary hover:bg-tertiary hover:text-primary'
|
||||
}
|
||||
`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
className={`flex-shrink-0 transition-transform ${isActive ? '' : 'group-hover:scale-105'
|
||||
}`}
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
size={16}
|
||||
className={`
|
||||
flex-shrink-0 transition-transform duration-fast
|
||||
${isActive ? 'text-accent' : 'group-hover:scale-105'}
|
||||
`}
|
||||
strokeWidth={isActive ? 2 : 1.5}
|
||||
/>
|
||||
<span className="truncate text-sm">{item.label}</span>
|
||||
<span className="truncate text-[13px]">{item.label}</span>
|
||||
|
||||
{/* Active indicator - subtle accent dot */}
|
||||
{isActive && (
|
||||
<div className="ml-auto w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<div className="ml-auto w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
236
frontend/src/components/ui/LinearPrimitives.tsx
Normal file
236
frontend/src/components/ui/LinearPrimitives.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Linear-inspired UI primitives
|
||||
* Consistent components for the new design system
|
||||
*/
|
||||
|
||||
// Page header with title and optional actions
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<header className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-secondary text-sm mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// Section header for groupings
|
||||
interface SectionHeaderProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
count?: number;
|
||||
accent?: 'default' | 'accent' | 'success' | 'warning' | 'destructive';
|
||||
}
|
||||
|
||||
export function SectionHeader({ icon: Icon, title, count, accent = 'default' }: SectionHeaderProps) {
|
||||
const accentClasses = {
|
||||
default: 'bg-tertiary text-secondary',
|
||||
accent: 'bg-accent-muted text-accent',
|
||||
success: 'bg-success-muted text-success',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
destructive: 'bg-destructive-muted text-destructive',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{Icon && (
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center ${accentClasses[accent]}`}>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-xs font-medium text-tertiary uppercase tracking-wider">
|
||||
{title}
|
||||
</h3>
|
||||
{count !== undefined && (
|
||||
<span className="badge">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state component
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="w-12 h-12 mx-auto bg-tertiary rounded-xl flex items-center justify-center mb-4">
|
||||
<Icon size={24} className="text-tertiary" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-primary">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-tertiary text-sm mt-1 max-w-sm mx-auto">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Metric card for dashboards
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
accent?: 'default' | 'accent' | 'success' | 'warning' | 'destructive';
|
||||
trend?: { value: number; positive: boolean };
|
||||
}
|
||||
|
||||
export function MetricCard({ icon: Icon, label, value, subtitle, accent = 'default', trend }: MetricCardProps) {
|
||||
const accentClasses = {
|
||||
default: 'bg-tertiary text-secondary',
|
||||
accent: 'bg-accent-muted text-accent',
|
||||
success: 'bg-success-muted text-success',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
destructive: 'bg-destructive-muted text-destructive',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card card-interactive p-4">
|
||||
<div className={`w-8 h-8 rounded-md flex items-center justify-center mb-3 ${accentClasses[accent]}`}>
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-primary tracking-tight">{value}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-xs text-tertiary">{label}</p>
|
||||
{trend && (
|
||||
<span className={`text-xs font-medium ${trend.positive ? 'text-success' : 'text-destructive'}`}>
|
||||
{trend.positive ? '+' : ''}{trend.value}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <p className="text-[10px] text-tertiary mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// List item for tables/lists
|
||||
interface ListItemProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ListItem({ children, onClick, active, className = '' }: ListItemProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-md
|
||||
transition-colors duration-fast
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${active
|
||||
? 'bg-accent-muted border border-accent/20'
|
||||
: 'hover:bg-tertiary'
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Action button (icon button)
|
||||
interface ActionButtonProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'default' | 'accent' | 'success' | 'warning' | 'destructive';
|
||||
}
|
||||
|
||||
export function ActionButton({ icon: Icon, label, onClick, variant = 'default' }: ActionButtonProps) {
|
||||
const variantClasses = {
|
||||
default: 'text-secondary hover:text-primary hover:bg-tertiary',
|
||||
accent: 'text-secondary hover:text-accent hover:bg-accent-muted',
|
||||
success: 'text-secondary hover:text-success hover:bg-success-muted',
|
||||
warning: 'text-secondary hover:text-warning hover:bg-warning-muted',
|
||||
destructive: 'text-secondary hover:text-destructive hover:bg-destructive-muted',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
className={`p-2 rounded-md transition-colors duration-fast ${variantClasses[variant]}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge
|
||||
interface StatusBadgeProps {
|
||||
status: 'active' | 'pending' | 'completed' | 'error' | 'default';
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||
const statusClasses = {
|
||||
active: 'badge-success',
|
||||
pending: 'badge-warning',
|
||||
completed: 'badge-accent',
|
||||
error: 'badge-destructive',
|
||||
default: 'badge',
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
active: 'Active',
|
||||
pending: 'Pending',
|
||||
completed: 'Completed',
|
||||
error: 'Error',
|
||||
default: label || 'Unknown',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`badge ${statusClasses[status]}`}>
|
||||
{label || statusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`skeleton ${className}`} />;
|
||||
}
|
||||
|
||||
// Card skeleton
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<Skeleton className="w-8 h-8 rounded-md" />
|
||||
<Skeleton className="w-3/4 h-5" />
|
||||
<Skeleton className="w-1/2 h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Divider
|
||||
export function Divider({ className = '' }: { className?: string }) {
|
||||
return <div className={`divider my-4 ${className}`} />;
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
/* Linear-Inspired Design System v2.0 */
|
||||
/* Fonts: Space Grotesk for headlines, Inter for body */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
|
@ -6,51 +8,127 @@
|
|||
|
||||
@layer base {
|
||||
|
||||
/* Root variables */
|
||||
/* ============================================
|
||||
CSS Custom Properties (Design Tokens)
|
||||
============================================ */
|
||||
:root {
|
||||
--font-sans: 'Space Grotesk', 'SF Pro Display', system-ui, -apple-system, sans-serif;
|
||||
/* Font families */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-display: 'Space Grotesk', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
|
||||
/* Transition timing */
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Duration */
|
||||
--duration-instant: 50ms;
|
||||
--duration-fast: 100ms;
|
||||
--duration-normal: 150ms;
|
||||
--duration-slow: 250ms;
|
||||
--duration-slower: 400ms;
|
||||
|
||||
/* Light mode (Bone) */
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-secondary: #FAFAFA;
|
||||
--color-bg-tertiary: #F5F5F5;
|
||||
--color-bg-elevated: #FFFFFF;
|
||||
|
||||
--color-text-primary: #171717;
|
||||
--color-text-secondary: #525252;
|
||||
--color-text-tertiary: #A3A3A3;
|
||||
--color-text-quaternary: #D4D4D4;
|
||||
|
||||
--color-border-default: #E5E5E5;
|
||||
--color-border-subtle: #F5F5F5;
|
||||
--color-border-strong: #D4D4D4;
|
||||
|
||||
--color-accent: #5E6AD2;
|
||||
--color-accent-hover: #6E7AE2;
|
||||
--color-accent-muted: rgba(94, 106, 210, 0.15);
|
||||
|
||||
/* shadcn/ui compatibility */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 235 58% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 96%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96%;
|
||||
--muted-foreground: 0 0% 45%;
|
||||
--accent: 235 58% 60%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 0 0% 90%;
|
||||
--input: 0 0% 90%;
|
||||
--ring: 235 58% 60%;
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
/* Base styles - Using 100% honors browser/OS text size settings */
|
||||
/* Dark mode (Charcoal) */
|
||||
.dark {
|
||||
--color-bg-primary: #0A0A0A;
|
||||
--color-bg-secondary: #111111;
|
||||
--color-bg-tertiary: #1A1A1A;
|
||||
--color-bg-elevated: #171717;
|
||||
|
||||
--color-text-primary: #FAFAFA;
|
||||
--color-text-secondary: #A3A3A3;
|
||||
--color-text-tertiary: #737373;
|
||||
--color-text-quaternary: #525252;
|
||||
|
||||
--color-border-default: #262626;
|
||||
--color-border-subtle: #1F1F1F;
|
||||
--color-border-strong: #404040;
|
||||
|
||||
/* shadcn/ui compatibility */
|
||||
--background: 0 0% 4%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 235 58% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 15%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 64%;
|
||||
--accent: 235 58% 60%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 0 0% 15%;
|
||||
--input: 0 0% 15%;
|
||||
--ring: 235 58% 60%;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Base Styles
|
||||
============================================ */
|
||||
html {
|
||||
font-size: 100%;
|
||||
/* Respects user's browser settings (typically 16px default) */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scroll-behavior: smooth;
|
||||
/* Allow up to 200% zoom without horizontal scroll */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Scale up base font for users with larger text preferences */
|
||||
@media screen and (min-resolution: 1dppx) {
|
||||
html {
|
||||
/* Uses browser's default, which respects system accessibility settings */
|
||||
font-size: clamp(100%, 1vw + 0.75rem, 125%);
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--contrast-multiplier: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
/* Forced colors (Windows High Contrast) */
|
||||
@media (forced-colors: active) {
|
||||
* {
|
||||
border-color: currentColor !important;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-50;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: 'ss01' 1, 'ss02' 1;
|
||||
font-feature-settings: 'ss01' 1, 'cv01' 1;
|
||||
letter-spacing: -0.011em;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
transition:
|
||||
background-color var(--duration-slow) var(--ease-out-expo),
|
||||
color var(--duration-slow) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
/* Code */
|
||||
|
|
@ -61,7 +139,52 @@
|
|||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Touch targets */
|
||||
/* Headings - Space Grotesk */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Interactive Elements
|
||||
============================================ */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
|
|
@ -73,163 +196,143 @@
|
|||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Focus */
|
||||
/* Global interactive transition */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[role="button"],
|
||||
[tabindex="0"] {
|
||||
transition:
|
||||
background-color var(--duration-fast) var(--ease-out-expo),
|
||||
border-color var(--duration-fast) var(--ease-out-expo),
|
||||
color var(--duration-fast) var(--ease-out-expo),
|
||||
box-shadow var(--duration-normal) var(--ease-out-expo),
|
||||
transform var(--duration-fast) var(--ease-spring);
|
||||
}
|
||||
|
||||
/* Focus ring (Linear-style) */
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-emerald-500 ring-offset-2;
|
||||
transition: box-shadow 0.2s ease;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-bg-primary), 0 0 0 4px var(--color-accent);
|
||||
transition: box-shadow var(--duration-normal) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
/* Skip link */
|
||||
.skip-to-main {
|
||||
@apply absolute left-0 top-0 -translate-y-full bg-emerald-600 text-white px-4 py-2 rounded-br-lg font-medium;
|
||||
@apply focus:translate-y-0 z-50;
|
||||
transition: transform 0.2s ease;
|
||||
/* Subtle press effect */
|
||||
button:active:not(:disabled),
|
||||
[role="button"]:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
/* ============================================
|
||||
Typography
|
||||
============================================ */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-bold tracking-tight;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl md:text-5xl;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-3xl md:text-4xl;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-2xl md:text-3xl;
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-xl md:text-2xl;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
@apply text-emerald-600 dark:text-emerald-400;
|
||||
transition: color 0.15s ease;
|
||||
/* Links (subtle, no color, underline on hover) */
|
||||
a:not(.btn):not([class*="bg-"]) {
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-fast) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@apply text-emerald-700 dark:text-emerald-300;
|
||||
a:not(.btn):not([class*="bg-"]):hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
@apply bg-emerald-500/20 text-emerald-900 dark:text-emerald-100;
|
||||
background-color: var(--color-accent-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Reduced Motion
|
||||
============================================ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
High Contrast Mode
|
||||
============================================ */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--color-border-default: #171717;
|
||||
--color-border-subtle: #525252;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-border-default: #FAFAFA;
|
||||
--color-border-subtle: #A3A3A3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
/* Scrollbar */
|
||||
/* ============================================
|
||||
Custom Scrollbar (Linear-style)
|
||||
============================================ */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
@apply w-2 h-2;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
@apply bg-slate-100 dark:bg-slate-800;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-emerald-400 dark:bg-emerald-600 rounded-full;
|
||||
transition: background-color 0.2s ease;
|
||||
background-color: var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-emerald-500;
|
||||
background-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Slide in animation */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Scale in animation */
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Slide up animation for mobile sheets */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* No scrollbar utility */
|
||||
/* Hide scrollbar */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -239,30 +342,211 @@
|
|||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Shimmer effect */
|
||||
/* ============================================
|
||||
Button Styles
|
||||
============================================ */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-destructive {
|
||||
background-color: #EF4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-destructive:hover:not(:disabled) {
|
||||
background-color: #DC2626;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Card Styles
|
||||
============================================ */
|
||||
.card {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-out-expo),
|
||||
box-shadow var(--duration-normal) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark .card-interactive:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Input Styles
|
||||
============================================ */
|
||||
.input {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-primary);
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-out-expo),
|
||||
box-shadow var(--duration-normal) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-muted);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Badge Styles
|
||||
============================================ */
|
||||
.badge {
|
||||
@apply inline-flex items-center justify-center;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.badge-accent {
|
||||
background-color: var(--color-accent-muted);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
color: #22C55E;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: rgba(234, 179, 8, 0.15);
|
||||
color: #CA8A04;
|
||||
}
|
||||
|
||||
.badge-destructive {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Skeleton Loading (Shimmer)
|
||||
============================================ */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg,
|
||||
var(--color-bg-tertiary) 0%,
|
||||
var(--color-bg-secondary) 50%,
|
||||
var(--color-bg-tertiary) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
rgba(255, 255, 255, 0) 100%);
|
||||
background-size: 1000px 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
/* ============================================
|
||||
Tooltip
|
||||
============================================ */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--color-text-primary);
|
||||
color: var(--color-bg-primary);
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity var(--duration-fast) var(--ease-out-expo),
|
||||
transform var(--duration-normal) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.tooltip.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Divider
|
||||
============================================ */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Screen reader only - visually hidden but accessible */
|
||||
/* Screen reader only */
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
|
|
@ -275,86 +559,108 @@
|
|||
border: 0 !important;
|
||||
}
|
||||
|
||||
/* Not screen reader only - opposite of sr-only */
|
||||
.not-sr-only {
|
||||
position: static !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
overflow: visible !important;
|
||||
clip: auto !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
/* Focus visible only (hide when not keyboard focused) */
|
||||
.focus-visible-only:not(:focus-visible) {
|
||||
@apply sr-only;
|
||||
}
|
||||
|
||||
/* High contrast mode enhancements */
|
||||
@media (prefers-contrast: more) {
|
||||
.high-contrast-border {
|
||||
@apply border-2 border-current;
|
||||
}
|
||||
|
||||
.high-contrast-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Increase button contrast */
|
||||
button:not([disabled]),
|
||||
[role="button"]:not([disabled]) {
|
||||
@apply border border-current;
|
||||
}
|
||||
|
||||
/* Stronger focus indicator */
|
||||
*:focus-visible {
|
||||
@apply ring-4 ring-offset-4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large text mode support */
|
||||
.text-scalable {
|
||||
font-size: clamp(1rem, 2vw, 1.25rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Respect user motion preferences */
|
||||
.motion-safe {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.motion-reduce {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly spacing */
|
||||
/* Touch target */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
/* Color blind friendly patterns */
|
||||
.pattern-stripe {
|
||||
background-image: repeating-linear-gradient(45deg,
|
||||
transparent,
|
||||
transparent 5px,
|
||||
currentColor 5px,
|
||||
currentColor 10px);
|
||||
opacity: 0.1;
|
||||
/* Text colors using CSS vars */
|
||||
.text-primary {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.pattern-dots {
|
||||
background-image: radial-gradient(currentColor 1px,
|
||||
transparent 1px);
|
||||
background-size: 8px 8px;
|
||||
opacity: 0.1;
|
||||
.text-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.text-tertiary {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.text-quaternary {
|
||||
color: var(--color-text-quaternary);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Background colors using CSS vars */
|
||||
.bg-primary {
|
||||
background-color: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.bg-secondary {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.bg-tertiary {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.bg-elevated {
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
/* Border colors using CSS vars */
|
||||
.border-default {
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.border-strong {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-in {
|
||||
animation: fade-in var(--duration-normal) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up var(--duration-slow) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in var(--duration-normal) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +1,28 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Shield, Search, Filter, Download, Calendar, ChevronLeft, ChevronRight,
|
||||
User, Clock, Eye, X, RefreshCw, FileText, AlertTriangle, CheckCircle,
|
||||
Shield, Search, Filter, Download, ChevronLeft, ChevronRight,
|
||||
User, Clock, Eye, X, Loader2, FileText,
|
||||
LogIn, LogOut, Edit, Trash2, FileDown, ThumbsUp, ThumbsDown
|
||||
} from 'lucide-react';
|
||||
import { auditApi, AuditLog, AuditLogSummary, AuditLogFilters } from '../lib/auditApi';
|
||||
import { PageHeader, MetricCard, EmptyState } from '../components/ui/LinearPrimitives';
|
||||
|
||||
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string }> = {
|
||||
CREATE: { icon: FileText, color: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/30', label: 'Created' },
|
||||
UPDATE: { icon: Edit, color: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30', label: 'Updated' },
|
||||
DELETE: { icon: Trash2, color: 'text-red-500 bg-red-50 dark:bg-red-900/30', label: 'Deleted' },
|
||||
LOGIN: { icon: LogIn, color: 'text-indigo-500 bg-indigo-50 dark:bg-indigo-900/30', label: 'Login' },
|
||||
LOGOUT: { icon: LogOut, color: 'text-slate-500 bg-slate-50 dark:bg-slate-700', label: 'Logout' },
|
||||
ACCESS: { icon: Eye, color: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30', label: 'Accessed' },
|
||||
EXPORT: { icon: FileDown, color: 'text-amber-500 bg-amber-50 dark:bg-amber-900/30', label: 'Exported' },
|
||||
APPROVE: { icon: ThumbsUp, color: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/30', label: 'Approved' },
|
||||
REJECT: { icon: ThumbsDown, color: 'text-red-500 bg-red-50 dark:bg-red-900/30', label: 'Rejected' }
|
||||
const ACTION_CONFIG: Record<string, { icon: React.ElementType; badge: string; label: string }> = {
|
||||
CREATE: { icon: FileText, badge: 'badge-success', label: 'Created' },
|
||||
UPDATE: { icon: Edit, badge: 'badge-accent', label: 'Updated' },
|
||||
DELETE: { icon: Trash2, badge: 'badge-destructive', label: 'Deleted' },
|
||||
LOGIN: { icon: LogIn, badge: 'badge-accent', label: 'Login' },
|
||||
LOGOUT: { icon: LogOut, badge: 'badge', label: 'Logout' },
|
||||
ACCESS: { icon: Eye, badge: 'badge-accent', label: 'Accessed' },
|
||||
EXPORT: { icon: FileDown, badge: 'badge-warning', label: 'Exported' },
|
||||
APPROVE: { icon: ThumbsUp, badge: 'badge-success', label: 'Approved' },
|
||||
REJECT: { icon: ThumbsDown, badge: 'badge-destructive', label: 'Rejected' }
|
||||
};
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
Batch: 'Batch',
|
||||
Room: 'Room',
|
||||
Task: 'Task',
|
||||
User: 'User',
|
||||
Visitor: 'Visitor',
|
||||
Document: 'Document',
|
||||
AuditLog: 'Audit Log',
|
||||
Plant: 'Plant',
|
||||
Supply: 'Supply'
|
||||
Batch: 'Batch', Room: 'Room', Task: 'Task', User: 'User',
|
||||
Visitor: 'Visitor', Document: 'Document', AuditLog: 'Audit Log',
|
||||
Plant: 'Plant', Supply: 'Supply'
|
||||
};
|
||||
|
||||
export default function AuditLogPage() {
|
||||
|
|
@ -37,11 +32,7 @@ export default function AuditLogPage() {
|
|||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
|
||||
// Filters
|
||||
const [filters, setFilters] = useState<AuditLogFilters>({
|
||||
page: 1,
|
||||
limit: 25
|
||||
});
|
||||
const [filters, setFilters] = useState<AuditLogFilters>({ page: 1, limit: 25 });
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, pages: 0 });
|
||||
|
|
@ -106,85 +97,64 @@ export default function AuditLogPage() {
|
|||
}
|
||||
|
||||
function getActionConfig(action: string) {
|
||||
return ACTION_CONFIG[action] || { icon: FileText, color: 'text-slate-500 bg-slate-50', label: action };
|
||||
return ACTION_CONFIG[action] || { icon: FileText, badge: 'badge', label: action };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Shield className="text-indigo-500" />
|
||||
Audit Log
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">Complete activity trail for compliance</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm font-medium flex items-center gap-2 transition-colors ${showFilters
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700 dark:bg-indigo-900/30 dark:border-indigo-700 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
Filters
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="px-3 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium flex items-center gap-2 hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Download size={16} />
|
||||
{exporting ? 'Exporting...' : 'Export CSV'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="Audit Log"
|
||||
subtitle="Complete activity trail for compliance"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`btn ${showFilters ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Download size={16} />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<p className="text-2xl font-bold text-indigo-600">{summary.totalLogs.toLocaleString()}</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Total Events</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<p className="text-2xl font-bold text-emerald-600">{summary.byAction['CREATE'] || 0}</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Creates</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<p className="text-2xl font-bold text-blue-600">{summary.byAction['UPDATE'] || 0}</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Updates</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<p className="text-2xl font-bold text-red-600">{summary.byAction['DELETE'] || 0}</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Deletes</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard icon={Shield} label="Total Events" value={summary.totalLogs.toLocaleString()} accent="accent" />
|
||||
<MetricCard icon={FileText} label="Creates" value={summary.byAction['CREATE'] || 0} accent="success" />
|
||||
<MetricCard icon={Edit} label="Updates" value={summary.byAction['UPDATE'] || 0} accent="accent" />
|
||||
<MetricCard icon={Trash2} label="Deletes" value={summary.byAction['DELETE'] || 0} accent="destructive" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters Panel */}
|
||||
{showFilters && (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* Search */}
|
||||
<div className="card p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by user or entity..."
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
className="input w-full pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entity Filter */}
|
||||
<select
|
||||
value={filters.entity || ''}
|
||||
onChange={(e) => setFilters({ ...filters, entity: e.target.value || undefined, page: 1 })}
|
||||
className="px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Entities</option>
|
||||
{summary?.byEntity.map(e => (
|
||||
|
|
@ -194,11 +164,10 @@ export default function AuditLogPage() {
|
|||
))}
|
||||
</select>
|
||||
|
||||
{/* Action Filter */}
|
||||
<select
|
||||
value={filters.action || ''}
|
||||
onChange={(e) => setFilters({ ...filters, action: e.target.value || undefined, page: 1 })}
|
||||
className="px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
<option value="CREATE">Create</option>
|
||||
|
|
@ -206,33 +175,28 @@ export default function AuditLogPage() {
|
|||
<option value="DELETE">Delete</option>
|
||||
<option value="LOGIN">Login</option>
|
||||
<option value="LOGOUT">Logout</option>
|
||||
<option value="EXPORT">Export</option>
|
||||
<option value="APPROVE">Approve</option>
|
||||
<option value="REJECT">Reject</option>
|
||||
</select>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate?.split('T')[0] || ''}
|
||||
onChange={(e) => setFilters({ ...filters, startDate: e.target.value ? new Date(e.target.value).toISOString() : undefined, page: 1 })}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
|
||||
className="input flex-1 h-10 text-xs"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate?.split('T')[0] || ''}
|
||||
onChange={(e) => setFilters({ ...filters, endDate: e.target.value ? new Date(e.target.value).toISOString() : undefined, page: 1 })}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
|
||||
className="input flex-1 h-10 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(filters.entity || filters.action || filters.startDate || filters.endDate) && (
|
||||
<button
|
||||
onClick={() => setFilters({ page: 1, limit: 25 })}
|
||||
className="mt-3 text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
||||
className="mt-3 text-sm text-accent hover:underline flex items-center gap-1"
|
||||
>
|
||||
<X size={14} /> Clear filters
|
||||
</button>
|
||||
|
|
@ -241,84 +205,81 @@ export default function AuditLogPage() {
|
|||
)}
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div className="card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<RefreshCw className="animate-spin text-slate-400" size={24} />
|
||||
<Loader2 className="animate-spin text-tertiary" size={24} />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-20 text-slate-500">
|
||||
<Shield className="mx-auto mb-3 text-slate-300" size={48} />
|
||||
<p>No audit logs found</p>
|
||||
<p className="text-sm mt-1">Activity will appear here as users interact with the system</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title="No audit logs found"
|
||||
description="Activity will appear here as users interact with the system."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<thead className="bg-secondary border-b border-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-slate-500 uppercase">Timestamp</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-slate-500 uppercase">User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-slate-500 uppercase">Action</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-slate-500 uppercase">Entity</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-slate-500 uppercase">Details</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-slate-500 uppercase">View</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Time</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Action</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Entity</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Details</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-tertiary uppercase tracking-wider">View</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<tbody className="divide-y divide-subtle">
|
||||
{logs.map(log => {
|
||||
const config = getActionConfig(log.action);
|
||||
const ActionIcon = config.icon;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="hover:bg-slate-50 dark:hover:bg-slate-700/30 cursor-pointer"
|
||||
className="hover:bg-tertiary cursor-pointer transition-colors duration-fast"
|
||||
onClick={() => setSelectedLog(log)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<Clock size={14} className="text-slate-400" />
|
||||
<div className="flex items-center gap-2 text-xs text-secondary">
|
||||
<Clock size={12} className="text-tertiary" />
|
||||
<span>{formatTimestamp(log.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
<p className="text-[10px] text-tertiary mt-0.5">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-slate-100 dark:bg-slate-600 flex items-center justify-center">
|
||||
<User size={14} className="text-slate-500" />
|
||||
<div className="w-6 h-6 rounded-full bg-tertiary flex items-center justify-center">
|
||||
<User size={12} className="text-tertiary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{log.userName || 'System'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${config.color}`}>
|
||||
<ActionIcon size={12} />
|
||||
<span className={`${config.badge} text-[10px]`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{ENTITY_LABELS[log.entity] || log.entity}
|
||||
</span>
|
||||
{log.entityName && (
|
||||
<p className="text-xs text-slate-500 truncate max-w-[150px]">{log.entityName}</p>
|
||||
<p className="text-xs text-tertiary truncate max-w-[120px]">{log.entityName}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-xs text-slate-500 truncate max-w-[200px]">
|
||||
<p className="text-xs text-tertiary truncate max-w-[150px]">
|
||||
{log.changes ? `Changed: ${Object.keys(log.changes).join(', ')}` :
|
||||
log.ipAddress ? `IP: ${log.ipAddress}` : '—'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-600 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||
<Eye size={16} />
|
||||
<button className="p-1.5 rounded hover:bg-tertiary text-tertiary hover:text-primary transition-colors">
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -329,25 +290,23 @@ export default function AuditLogPage() {
|
|||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 dark:border-slate-700">
|
||||
<p className="text-sm text-slate-500">
|
||||
Showing {((pagination.page - 1) * pagination.limit) + 1} to {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} entries
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-subtle">
|
||||
<p className="text-xs text-tertiary">
|
||||
{((pagination.page - 1) * pagination.limit) + 1}–{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: pagination.page - 1 })}
|
||||
disabled={pagination.page <= 1}
|
||||
className="p-2 rounded-lg border border-slate-200 dark:border-slate-600 disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||
className="btn btn-ghost p-2 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Page {pagination.page} of {pagination.pages}
|
||||
</span>
|
||||
<span className="text-xs text-secondary px-2">{pagination.page}/{pagination.pages}</span>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: pagination.page + 1 })}
|
||||
disabled={pagination.page >= pagination.pages}
|
||||
className="p-2 rounded-lg border border-slate-200 dark:border-slate-600 disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||
className="btn btn-ghost p-2 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
|
|
@ -359,105 +318,91 @@ export default function AuditLogPage() {
|
|||
|
||||
{/* Detail Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" onClick={() => setSelectedLog(null)}>
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 animate-fade-in" onClick={() => setSelectedLog(null)}>
|
||||
<div
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden shadow-2xl"
|
||||
className="card max-w-2xl w-full max-h-[80vh] overflow-hidden animate-scale-in"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700 flex justify-between items-start">
|
||||
<div className="p-4 border-b border-subtle flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Audit Log Detail</h3>
|
||||
<p className="text-sm text-slate-500">Event ID: {selectedLog.id.slice(0, 8)}...</p>
|
||||
<h3 className="text-lg font-semibold text-primary">Audit Log Detail</h3>
|
||||
<p className="text-xs text-tertiary font-mono">ID: {selectedLog.id.slice(0, 8)}...</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
className="p-2 rounded-md hover:bg-tertiary transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
<X size={16} className="text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto max-h-[60vh] space-y-4">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Timestamp</p>
|
||||
<p className="text-sm text-slate-900 dark:text-white">{new Date(selectedLog.timestamp).toLocaleString()}</p>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Timestamp</p>
|
||||
<p className="text-sm text-primary">{new Date(selectedLog.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">User</p>
|
||||
<p className="text-sm text-slate-900 dark:text-white">{selectedLog.userName || 'System'}</p>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">User</p>
|
||||
<p className="text-sm text-primary">{selectedLog.userName || 'System'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Action</p>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${getActionConfig(selectedLog.action).color}`}>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Action</p>
|
||||
<span className={`${getActionConfig(selectedLog.action).badge} text-[10px]`}>
|
||||
{getActionConfig(selectedLog.action).label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Entity</p>
|
||||
<p className="text-sm text-slate-900 dark:text-white">
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Entity</p>
|
||||
<p className="text-sm text-primary">
|
||||
{ENTITY_LABELS[selectedLog.entity] || selectedLog.entity}
|
||||
{selectedLog.entityName && <span className="text-slate-500 ml-1">({selectedLog.entityName})</span>}
|
||||
{selectedLog.entityName && <span className="text-tertiary ml-1">({selectedLog.entityName})</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IP Address */}
|
||||
{selectedLog.ipAddress && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">IP Address</p>
|
||||
<p className="text-sm font-mono text-slate-900 dark:text-white">{selectedLog.ipAddress}</p>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">IP Address</p>
|
||||
<p className="text-sm font-mono text-primary">{selectedLog.ipAddress}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes */}
|
||||
{selectedLog.changes && Object.keys(selectedLog.changes).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Changes</p>
|
||||
<div className="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 space-y-2">
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-2">Changes</p>
|
||||
<div className="bg-tertiary rounded-md p-3 space-y-2">
|
||||
{Object.entries(selectedLog.changes).map(([key, value]: [string, any]) => (
|
||||
<div key={key} className="flex items-start gap-2 text-sm">
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 min-w-[100px]">{key}:</span>
|
||||
<span className="text-red-500 line-through">{JSON.stringify(value.from)}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="text-emerald-600">{JSON.stringify(value.to)}</span>
|
||||
<div key={key} className="flex items-start gap-2 text-xs">
|
||||
<span className="font-medium text-secondary min-w-[80px]">{key}:</span>
|
||||
<span className="text-destructive line-through">{JSON.stringify(value.from)}</span>
|
||||
<span className="text-tertiary">→</span>
|
||||
<span className="text-success">{JSON.stringify(value.to)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Before/After Data */}
|
||||
{(selectedLog.before || selectedLog.after) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedLog.before && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Before</p>
|
||||
<pre className="text-xs bg-red-50 dark:bg-red-900/20 p-2 rounded overflow-x-auto max-h-40 text-red-700 dark:text-red-400">
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Before</p>
|
||||
<pre className="text-[10px] bg-destructive-muted p-2 rounded-md overflow-x-auto max-h-32 text-destructive font-mono">
|
||||
{JSON.stringify(selectedLog.before, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.after && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">After</p>
|
||||
<pre className="text-xs bg-emerald-50 dark:bg-emerald-900/20 p-2 rounded overflow-x-auto max-h-40 text-emerald-700 dark:text-emerald-400">
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">After</p>
|
||||
<pre className="text-[10px] bg-success-muted p-2 rounded-md overflow-x-auto max-h-32 text-success font-mono">
|
||||
{JSON.stringify(selectedLog.after, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{selectedLog.metadata && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Additional Metadata</p>
|
||||
<pre className="text-xs bg-slate-50 dark:bg-slate-700/50 p-2 rounded overflow-x-auto max-h-40">
|
||||
{JSON.stringify(selectedLog.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search, RefreshCw } from 'lucide-react';
|
||||
import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search } from 'lucide-react';
|
||||
import { Batch, batchesApi } from '../lib/batchesApi';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import BatchTransitionModal from '../components/BatchTransitionModal';
|
||||
|
|
@ -7,17 +7,17 @@ import WeightLogModal from '../components/WeightLogModal';
|
|||
import CreateTaskModal from '../components/tasks/CreateTaskModal';
|
||||
import IPMScheduleModal from '../components/IPMScheduleModal';
|
||||
import ScoutingModal from '../components/ipm/ScoutingModal';
|
||||
import { SkeletonCard } from '../components/ui/Skeleton';
|
||||
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
||||
import { QuickLogBar } from '../components/touchpoints/QuickLogButtons';
|
||||
import { PageHeader, SectionHeader, EmptyState, ActionButton, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
const STAGE_GROUPS = [
|
||||
{ id: 'CLONE_IN', label: 'Clones', icon: Sprout, color: 'text-blue-500 bg-blue-50 border-blue-100' },
|
||||
{ id: 'VEGETATIVE', label: 'Veg', icon: Leaf, color: 'text-emerald-500 bg-emerald-50 border-emerald-100' },
|
||||
{ id: 'FLOWERING', label: 'Flower', icon: Flower, color: 'text-purple-500 bg-purple-50 border-purple-100' },
|
||||
{ id: 'DRYING', label: 'Drying', icon: Archive, color: 'text-amber-500 bg-amber-50 border-amber-100' },
|
||||
{ id: 'CURING', label: 'Curing', icon: Archive, color: 'text-orange-500 bg-orange-50 border-orange-100' },
|
||||
{ id: 'FINISHED', label: 'Finished', icon: Archive, color: 'text-slate-500 bg-slate-50 border-slate-100' },
|
||||
{ id: 'CLONE_IN', label: 'Clones', icon: Sprout, accent: 'accent' as const },
|
||||
{ id: 'VEGETATIVE', label: 'Vegetative', icon: Leaf, accent: 'success' as const },
|
||||
{ id: 'FLOWERING', label: 'Flowering', icon: Flower, accent: 'accent' as const },
|
||||
{ id: 'DRYING', label: 'Drying', icon: Archive, accent: 'warning' as const },
|
||||
{ id: 'CURING', label: 'Curing', icon: Archive, accent: 'warning' as const },
|
||||
{ id: 'FINISHED', label: 'Finished', icon: Archive, accent: 'default' as const },
|
||||
];
|
||||
|
||||
export default function BatchesPage() {
|
||||
|
|
@ -48,7 +48,6 @@ export default function BatchesPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// Group batches by stage
|
||||
const groupedBatches = STAGE_GROUPS.map(group => ({
|
||||
...group,
|
||||
items: batches.filter(b => b.stage === group.id)
|
||||
|
|
@ -60,123 +59,110 @@ export default function BatchesPage() {
|
|||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handlePullRefresh} disabled={loading}>
|
||||
<div className="space-y-6 pb-20">
|
||||
<header className="flex justify-between items-center sticky top-0 bg-slate-50 dark:bg-slate-900 z-10 py-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Batches</h2>
|
||||
<p className="text-sm text-slate-500">{loading ? 'Loading...' : `${batches.length} active batches`}</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors shadow-lg">
|
||||
<Plus size={20} />
|
||||
<span className="hidden md:inline">Start Batch</span>
|
||||
</button>
|
||||
</header>
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="Batches"
|
||||
subtitle={loading ? 'Loading...' : `${batches.length} active batches`}
|
||||
actions={
|
||||
<button className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
<span className="hidden md:inline">New Batch</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-8">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : groupedBatches.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white dark:bg-slate-800 rounded-xl border border-dashed border-slate-300 dark:border-slate-700">
|
||||
<div className="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center mb-4">
|
||||
<Sprout size={32} className="text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">No active batches</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2">Get started by creating your first batch.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Sprout}
|
||||
title="No active batches"
|
||||
description="Get started by creating your first batch."
|
||||
action={
|
||||
<button className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
Start Batch
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
groupedBatches.map(group => (
|
||||
<div key={group.id} className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className={`p-2 rounded-lg ${group.color.split(' ')[1]}`}>
|
||||
<group.icon size={18} className={group.color.split(' ')[0]} />
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wide text-sm">
|
||||
{group.label}
|
||||
</h3>
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<SectionHeader
|
||||
icon={group.icon}
|
||||
title={group.label}
|
||||
count={group.items.length}
|
||||
accent={group.accent}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{group.items.map(batch => (
|
||||
<div
|
||||
key={batch.id}
|
||||
className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-100 dark:border-slate-700 shadow-sm hover:shadow-md transition-shadow relative group"
|
||||
className="card p-4 group"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h4 className="font-bold text-lg text-slate-900 dark:text-white leading-tight mb-1">
|
||||
<h4 className="font-medium text-primary text-sm mb-1">
|
||||
{batch.name}
|
||||
</h4>
|
||||
<div className="text-xs font-medium px-2 py-1 rounded bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 inline-block">
|
||||
{batch.strain}
|
||||
</div>
|
||||
<span className="badge">{batch.strain}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-fast">
|
||||
<ActionButton
|
||||
icon={ClipboardList}
|
||||
label="Add Task"
|
||||
onClick={() => setCreateTaskBatch(batch)}
|
||||
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded-lg transition-colors"
|
||||
title="Add Task"
|
||||
>
|
||||
<ClipboardList size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
variant="accent"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Bug}
|
||||
label="IPM"
|
||||
onClick={() => setIpmBatch(batch)}
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title="IPM Schedule"
|
||||
>
|
||||
<Bug size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
variant="destructive"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Search}
|
||||
label="Scout"
|
||||
onClick={() => setScoutingBatch(batch)}
|
||||
className="p-2 text-slate-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
title="Scout Batch"
|
||||
>
|
||||
<Search size={20} />
|
||||
</button>
|
||||
|
||||
{/* Show Scale button for Harvest+ stages */}
|
||||
variant="accent"
|
||||
/>
|
||||
{['HARVEST', 'DRYING', 'CURING', 'FINISHED'].includes(batch.stage) && (
|
||||
<button
|
||||
<ActionButton
|
||||
icon={Scale}
|
||||
label="Log Weight"
|
||||
onClick={() => setWeightLogBatch(batch)}
|
||||
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
title="Log Weight"
|
||||
>
|
||||
<Scale size={20} />
|
||||
</button>
|
||||
variant="accent"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<ActionButton
|
||||
icon={MoveRight}
|
||||
label="Transition"
|
||||
onClick={() => setSelectedBatch(batch)}
|
||||
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded-lg transition-colors"
|
||||
title="Transition Stage"
|
||||
>
|
||||
<MoveRight size={20} />
|
||||
</button>
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-slate-500 dark:text-slate-400 pt-3 border-t border-slate-50 dark:border-slate-700">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs uppercase tracking-wider text-slate-400">Room</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
<div className="flex items-center justify-between text-xs text-tertiary pt-3 border-t border-subtle">
|
||||
<div>
|
||||
<span className="text-quaternary">Room</span>
|
||||
<span className="text-secondary ml-2 font-medium">
|
||||
{batch.room?.name || 'Unassigned'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs uppercase tracking-wider text-slate-400">Plants</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
<div>
|
||||
<span className="text-quaternary">Plants</span>
|
||||
<span className="text-secondary ml-2 font-medium">
|
||||
{batch.plantCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Log Bar */}
|
||||
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<div className="mt-3 pt-3 border-t border-subtle">
|
||||
<QuickLogBar
|
||||
batchId={batch.id}
|
||||
batchName={batch.name}
|
||||
|
|
@ -220,10 +206,7 @@ export default function BatchesPage() {
|
|||
initialBatchId={createTaskBatch.id}
|
||||
initialRoomId={createTaskBatch.roomId || undefined}
|
||||
onClose={() => setCreateTaskBatch(null)}
|
||||
onSuccess={() => {
|
||||
// Optional: show toast
|
||||
setCreateTaskBatch(null);
|
||||
}}
|
||||
onSuccess={() => setCreateTaskBatch(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -238,9 +221,7 @@ export default function BatchesPage() {
|
|||
<ScoutingModal
|
||||
batch={scoutingBatch}
|
||||
onClose={() => setScoutingBatch(null)}
|
||||
onSuccess={() => {
|
||||
// Optional: show toast
|
||||
}}
|
||||
onSuccess={() => { }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,22 +10,22 @@ import {
|
|||
IrrigationCheckData,
|
||||
PlantHealthCheckData
|
||||
} from '../lib/walkthroughApi';
|
||||
import { ArrowLeft, Check, Loader2, AlertCircle, Droplets, Sprout, Bug } from 'lucide-react';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
type Step = 'start' | 'reservoir' | 'irrigation' | 'plant-health' | 'summary';
|
||||
|
||||
export default function DailyWalkthroughPage() {
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState<Step>('start');
|
||||
const [walkthroughId, setWalkthroughId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Store check data
|
||||
const [reservoirChecks, setReservoirChecks] = useState<ReservoirCheckData[]>([]);
|
||||
const [irrigationChecks, setIrrigationChecks] = useState<IrrigationCheckData[]>([]);
|
||||
const [plantHealthChecks, setPlantHealthChecks] = useState<PlantHealthCheckData[]>([]);
|
||||
|
||||
// Settings
|
||||
const [settings, setSettings] = useState<WalkthroughSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -35,31 +35,24 @@ export default function DailyWalkthroughPage() {
|
|||
const isPhotoRequired = (type: 'reservoir' | 'irrigation' | 'plantHealth') => {
|
||||
if (!settings) return false;
|
||||
const req = settings[`${type}Photos` as keyof WalkthroughSettings] as PhotoRequirement;
|
||||
|
||||
if (req === 'REQUIRED') return true;
|
||||
if (req === 'WEEKLY') return new Date().getDay() === 1; // Monday
|
||||
if (req === 'WEEKLY') return new Date().getDay() === 1;
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNextStep = (current: Step): Step => {
|
||||
if (!settings) return 'summary';
|
||||
|
||||
const sequence: Step[] = ['reservoir', 'irrigation', 'plant-health', 'summary'];
|
||||
const isEnabled = (s: Step) => {
|
||||
if (s === 'reservoir') return settings.enableReservoirs;
|
||||
if (s === 'irrigation') return settings.enableIrrigation;
|
||||
if (s === 'plant-health') return settings.enablePlantHealth;
|
||||
return true; // summary always enabled
|
||||
return true;
|
||||
};
|
||||
|
||||
if (current === 'start') {
|
||||
return sequence.find(s => isEnabled(s)) || 'summary';
|
||||
}
|
||||
|
||||
if (current === 'start') return sequence.find(s => isEnabled(s)) || 'summary';
|
||||
const idx = sequence.indexOf(current);
|
||||
if (idx === -1) return 'summary';
|
||||
|
||||
// Find next enabled step
|
||||
for (let i = idx + 1; i < sequence.length; i++) {
|
||||
if (isEnabled(sequence[i])) return sequence[i];
|
||||
}
|
||||
|
|
@ -69,7 +62,6 @@ export default function DailyWalkthroughPage() {
|
|||
const handleStartWalkthrough = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const walkthrough = await walkthroughApi.create();
|
||||
setWalkthroughId(walkthrough.id);
|
||||
|
|
@ -83,10 +75,8 @@ export default function DailyWalkthroughPage() {
|
|||
|
||||
const handleReservoirComplete = async (checks: ReservoirCheckData[]) => {
|
||||
if (!walkthroughId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
for (const check of checks) {
|
||||
await walkthroughApi.addReservoirCheck(walkthroughId, check);
|
||||
|
|
@ -102,10 +92,8 @@ export default function DailyWalkthroughPage() {
|
|||
|
||||
const handleIrrigationComplete = async (checks: IrrigationCheckData[]) => {
|
||||
if (!walkthroughId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
for (const check of checks) {
|
||||
await walkthroughApi.addIrrigationCheck(walkthroughId, check);
|
||||
|
|
@ -121,10 +109,8 @@ export default function DailyWalkthroughPage() {
|
|||
|
||||
const handlePlantHealthComplete = async (checks: PlantHealthCheckData[]) => {
|
||||
if (!walkthroughId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
for (const check of checks) {
|
||||
await walkthroughApi.addPlantHealthCheck(walkthroughId, check);
|
||||
|
|
@ -140,13 +126,11 @@ export default function DailyWalkthroughPage() {
|
|||
|
||||
const handleSubmitWalkthrough = async () => {
|
||||
if (!walkthroughId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await walkthroughApi.complete(walkthroughId);
|
||||
// Success! Navigate to dashboard
|
||||
addToast('Walkthrough completed!', 'success');
|
||||
navigate('/', { state: { message: 'Daily walkthrough completed!' } });
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to complete walkthrough');
|
||||
|
|
@ -186,36 +170,32 @@ export default function DailyWalkthroughPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// Summary Step
|
||||
if (currentStep === 'summary') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-emerald-900 to-slate-900 p-4 md:p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="min-h-screen bg-primary p-4 md:p-6 animate-in">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white mb-2">
|
||||
Review & Submit
|
||||
</h1>
|
||||
<p className="text-emerald-200">
|
||||
Review your walkthrough before submitting
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold text-primary">Review & Submit</h1>
|
||||
<p className="text-secondary text-sm">Review your walkthrough before submitting</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Reservoir Summary */}
|
||||
<div className="bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-xl p-6">
|
||||
<h3 className="font-bold text-lg mb-3 flex items-center gap-2">
|
||||
<span>💧</span> Reservoir Checks ({reservoirChecks.length})
|
||||
<div className="card p-5">
|
||||
<h3 className="font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<Droplets size={16} className="text-accent" />
|
||||
Reservoir Checks ({reservoirChecks.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{reservoirChecks.map((check, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
|
||||
<span className="font-medium">{check.tankName}</span>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${check.status === 'OK' ? 'bg-emerald-500 text-white' :
|
||||
check.status === 'LOW' ? 'bg-yellow-500 text-white' :
|
||||
'bg-red-500 text-white'
|
||||
}`}>
|
||||
{check.levelPercent}% - {check.status}
|
||||
<div key={i} className="flex justify-between items-center p-3 bg-tertiary rounded-md">
|
||||
<span className="text-sm text-primary">{check.tankName}</span>
|
||||
<span className={`
|
||||
badge text-[10px]
|
||||
${check.status === 'OK' ? 'badge-success' : check.status === 'LOW' ? 'badge-warning' : 'badge-destructive'}
|
||||
`}>
|
||||
{check.levelPercent}% — {check.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -223,27 +203,28 @@ export default function DailyWalkthroughPage() {
|
|||
</div>
|
||||
|
||||
{/* Irrigation Summary */}
|
||||
<div className="bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-xl p-6">
|
||||
<h3 className="font-bold text-lg mb-3 flex items-center gap-2">
|
||||
<span>🚿</span> Irrigation Checks ({irrigationChecks.length})
|
||||
<div className="card p-5">
|
||||
<h3 className="font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<Sprout size={16} className="text-accent" />
|
||||
Irrigation Checks ({irrigationChecks.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{irrigationChecks.map((check, i) => (
|
||||
<div key={i} className="p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
|
||||
<div key={i} className="p-3 bg-tertiary rounded-md">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">{check.zoneName}</span>
|
||||
<span className="text-sm">
|
||||
<span className="text-sm font-medium text-primary">{check.zoneName}</span>
|
||||
<span className="text-xs text-secondary">
|
||||
{check.drippersWorking}/{check.drippersTotal} working
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className={check.waterFlow ? 'text-emerald-600' : 'text-red-600'}>
|
||||
<div className="flex gap-3 text-[10px]">
|
||||
<span className={check.waterFlow ? 'text-success' : 'text-destructive'}>
|
||||
{check.waterFlow ? '✓' : '✗'} Water
|
||||
</span>
|
||||
<span className={check.nutrientsMixed ? 'text-emerald-600' : 'text-red-600'}>
|
||||
<span className={check.nutrientsMixed ? 'text-success' : 'text-destructive'}>
|
||||
{check.nutrientsMixed ? '✓' : '✗'} Nutrients
|
||||
</span>
|
||||
<span className={check.scheduleActive ? 'text-emerald-600' : 'text-red-600'}>
|
||||
<span className={check.scheduleActive ? 'text-success' : 'text-destructive'}>
|
||||
{check.scheduleActive ? '✓' : '✗'} Schedule
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -253,24 +234,25 @@ export default function DailyWalkthroughPage() {
|
|||
</div>
|
||||
|
||||
{/* Plant Health Summary */}
|
||||
<div className="bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-xl p-6">
|
||||
<h3 className="font-bold text-lg mb-3 flex items-center gap-2">
|
||||
<span>🌱</span> Plant Health Checks ({plantHealthChecks.length})
|
||||
<div className="card p-5">
|
||||
<h3 className="font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<Bug size={16} className="text-accent" />
|
||||
Plant Health Checks ({plantHealthChecks.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{plantHealthChecks.map((check, i) => (
|
||||
<div key={i} className="p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">{check.zoneName}</span>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${check.healthStatus === 'GOOD' ? 'bg-emerald-500 text-white' :
|
||||
check.healthStatus === 'FAIR' ? 'bg-yellow-500 text-white' :
|
||||
'bg-red-500 text-white'
|
||||
}`}>
|
||||
<div key={i} className="p-3 bg-tertiary rounded-md">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm font-medium text-primary">{check.zoneName}</span>
|
||||
<span className={`
|
||||
badge text-[10px]
|
||||
${check.healthStatus === 'GOOD' ? 'badge-success' : check.healthStatus === 'FAIR' ? 'badge-warning' : 'badge-destructive'}
|
||||
`}>
|
||||
{check.healthStatus}
|
||||
</span>
|
||||
</div>
|
||||
{check.pestsObserved && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
<div className="text-xs text-destructive mt-1">
|
||||
🐛 Pests: {check.pestType || 'Observed'}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -280,27 +262,28 @@ export default function DailyWalkthroughPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg">
|
||||
<div className="mb-4 p-3 bg-destructive-muted text-destructive rounded-md text-sm flex items-center gap-2">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setCurrentStep('plant-health')}
|
||||
disabled={isLoading}
|
||||
className="flex-1 min-h-[56px] py-4 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 font-bold rounded-xl transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
className="btn btn-secondary flex-1 h-12"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitWalkthrough}
|
||||
disabled={isLoading}
|
||||
className="flex-1 min-h-[56px] py-4 bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 text-white font-bold rounded-xl shadow-lg shadow-emerald-900/20 transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
className="btn btn-primary flex-1 h-12"
|
||||
>
|
||||
{isLoading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||
{isLoading ? 'Submitting...' : 'Submit Walkthrough'}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -311,94 +294,76 @@ export default function DailyWalkthroughPage() {
|
|||
|
||||
// Start Screen
|
||||
const steps = [
|
||||
{ id: 'reservoir', title: 'Reservoir Checks', description: 'Check all veg and flower tank levels', icon: '💧' },
|
||||
{ id: 'irrigation', title: 'Irrigation System', description: 'Verify drippers and water flow in all zones', icon: '🚿' },
|
||||
{ id: 'plant-health', title: 'Plant Health', description: 'Spot check for pests and plant health', icon: '🌱' },
|
||||
{ id: 'reservoir', title: 'Reservoir Checks', description: 'Check all veg and flower tank levels', icon: Droplets },
|
||||
{ id: 'irrigation', title: 'Irrigation System', description: 'Verify drippers and water flow', icon: Sprout },
|
||||
{ id: 'plant-health', title: 'Plant Health', description: 'Spot check for pests and health', icon: Bug },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-emerald-900 to-slate-900 p-4 md:p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="min-h-screen bg-primary p-4 md:p-6 animate-in">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white">
|
||||
Daily Walkthrough
|
||||
</h1>
|
||||
<p className="text-emerald-200 text-sm md:text-base mt-1">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center">
|
||||
<img
|
||||
src="/assets/logo-777-wolfpack.jpg"
|
||||
alt="777 Wolfpack"
|
||||
className="w-14 h-14 md:w-18 md:h-18 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-primary mb-1">Daily Walkthrough</h1>
|
||||
<p className="text-secondary text-sm">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl p-6 md:p-8">
|
||||
<div className="card p-6 md:p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-6xl mb-4">☀️</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
Good Morning!
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-300 text-base md:text-lg">
|
||||
Ready to start your daily facility walkthrough?
|
||||
</p>
|
||||
<div className="text-5xl mb-4">☀️</div>
|
||||
<h2 className="text-xl font-semibold text-primary mb-2">Good Morning!</h2>
|
||||
<p className="text-secondary">Ready to start your daily facility walkthrough?</p>
|
||||
</div>
|
||||
|
||||
{/* Steps Preview */}
|
||||
<div className="space-y-4 mb-8">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-4 p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl"
|
||||
>
|
||||
<div className="text-4xl">{step.icon}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-white text-base md:text-lg">
|
||||
{index + 1}. {step.title}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{step.description}
|
||||
</p>
|
||||
<div className="space-y-3 mb-8">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-4 p-4 bg-tertiary rounded-lg"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-muted flex items-center justify-center">
|
||||
<Icon size={20} className="text-accent" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-primary text-sm">
|
||||
{index + 1}. {step.title}
|
||||
</h3>
|
||||
<p className="text-xs text-tertiary">{step.description}</p>
|
||||
</div>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-subtle" />
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full border-2 border-slate-300 dark:border-slate-600" />
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg">
|
||||
<div className="mb-4 p-3 bg-destructive-muted text-destructive rounded-md text-sm flex items-center gap-2">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Button */}
|
||||
<button
|
||||
onClick={handleStartWalkthrough}
|
||||
disabled={isLoading}
|
||||
className="w-full min-h-[56px] py-4 bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 text-white text-lg font-bold rounded-xl shadow-lg shadow-emerald-900/20 transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
className="btn btn-primary w-full h-12"
|
||||
>
|
||||
{isLoading ? <Loader2 size={16} className="animate-spin" /> : null}
|
||||
{isLoading ? 'Starting...' : 'Start Walkthrough'}
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>💡 Tip:</strong> This walkthrough typically takes 15-20 minutes.
|
||||
Make sure you have your phone/tablet ready for photos.
|
||||
<div className="mt-6 p-4 bg-accent-muted rounded-lg border border-accent/20">
|
||||
<p className="text-xs text-accent">
|
||||
<strong>💡 Tip:</strong> This walkthrough typically takes 15–20 minutes.
|
||||
Have your device ready for photos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { Plus, Leaf, Users, ClipboardCheck, AlertTriangle, Activity, Droplet, Salad, Bug, Eye } from 'lucide-react';
|
||||
import { Plus, Leaf, ClipboardCheck, AlertTriangle, Activity, Droplet, Salad, Bug, Eye, ArrowRight } from 'lucide-react';
|
||||
import TouchPointModal from '../components/touchpoints/TouchPointModal';
|
||||
import TasksDueTodayWidget from '../components/tasks/TasksDueTodayWidget';
|
||||
import { SmartAlerts } from '../components/dashboard/SmartAlerts';
|
||||
import { touchPointsApi } from '../lib/touchPointsApi';
|
||||
import { analyticsApi, AnalyticsOverview } from '../lib/analyticsApi';
|
||||
import { SkeletonMetricCard } from '../components/ui/Skeleton';
|
||||
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
||||
|
||||
export default function DashboardPage() {
|
||||
|
|
@ -48,43 +47,55 @@ export default function DashboardPage() {
|
|||
};
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
const iconClass = "transition-transform duration-fast";
|
||||
switch (type) {
|
||||
case 'WATER': return <Droplet size={16} className="text-blue-500" />;
|
||||
case 'FEED': return <Salad size={16} className="text-emerald-500" />;
|
||||
case 'IPM': return <Bug size={16} className="text-red-500" />;
|
||||
case 'INSPECT': return <Eye size={16} className="text-purple-500" />;
|
||||
default: return <Activity size={16} className="text-slate-400" />;
|
||||
case 'WATER': return <Droplet size={14} className={`text-blue-500 ${iconClass}`} />;
|
||||
case 'FEED': return <Salad size={14} className={`text-success ${iconClass}`} />;
|
||||
case 'IPM': return <Bug size={14} className={`text-destructive ${iconClass}`} />;
|
||||
case 'INSPECT': return <Eye size={14} className={`text-accent ${iconClass}`} />;
|
||||
default: return <Activity size={14} className={`text-tertiary ${iconClass}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
const metrics = overview ? [
|
||||
{ label: 'Active Batches', value: overview.activeBatches.toString(), icon: Leaf, color: 'text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20' },
|
||||
{ label: 'Total Plants', value: overview.totalPlants.toString(), icon: Leaf, color: 'text-green-600 bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Tasks Completed (Week)', value: overview.tasksCompletedThisWeek.toString(), icon: ClipboardCheck, color: 'text-blue-600 bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Tasks Pending', value: overview.tasksPending.toString(), icon: AlertTriangle, color: 'text-amber-600 bg-amber-50 dark:bg-amber-900/20' },
|
||||
{ label: 'Touch Points Today', value: overview.touchPointsToday.toString(), icon: Activity, color: 'text-purple-600 bg-purple-50 dark:bg-purple-900/20' },
|
||||
{ label: 'Rooms', value: overview.totalRooms.toString(), icon: Users, color: 'text-slate-600 bg-slate-50 dark:bg-slate-700' },
|
||||
{ label: 'Active Batches', value: overview.activeBatches.toString(), icon: Leaf, accent: 'accent' },
|
||||
{ label: 'Total Plants', value: overview.totalPlants.toString(), icon: Leaf, accent: 'success' },
|
||||
{ label: 'Tasks Completed', value: overview.tasksCompletedThisWeek.toString(), icon: ClipboardCheck, accent: 'accent' },
|
||||
{ label: 'Tasks Pending', value: overview.tasksPending.toString(), icon: AlertTriangle, accent: 'warning' },
|
||||
{ label: 'Touch Points', value: overview.touchPointsToday.toString(), icon: Activity, accent: 'accent' },
|
||||
{ label: 'Rooms', value: overview.totalRooms.toString(), icon: Leaf, accent: 'neutral' },
|
||||
] : [];
|
||||
|
||||
const handlePullRefresh = async () => {
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const getAccentClasses = (accent: string) => {
|
||||
switch (accent) {
|
||||
case 'success': return 'bg-success-muted text-success';
|
||||
case 'warning': return 'bg-warning-muted text-warning';
|
||||
case 'destructive': return 'bg-destructive-muted text-destructive';
|
||||
case 'accent': return 'bg-accent-muted text-accent';
|
||||
default: return 'bg-tertiary text-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handlePullRefresh} disabled={loading}>
|
||||
<div className="space-y-6 pb-20">
|
||||
<header className="flex justify-between items-center bg-white dark:bg-slate-800 p-6 rounded-xl shadow-sm border border-neutral-200 dark:border-slate-700">
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-neutral-800 dark:text-white">
|
||||
Hello, {user?.name || user?.email?.split('@')[0]}
|
||||
</h2>
|
||||
<p className="text-neutral-500 dark:text-slate-400">
|
||||
Facility Overview • {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">
|
||||
Welcome back, {user?.name || user?.email?.split('@')[0]}
|
||||
</h1>
|
||||
<p className="text-secondary text-sm mt-1">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-2 bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300 rounded-lg font-medium text-sm flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />
|
||||
System Online
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-success-muted text-success rounded-md text-xs font-medium">
|
||||
<div className="w-1.5 h-1.5 bg-success rounded-full animate-pulse" />
|
||||
Online
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -92,82 +103,108 @@ export default function DashboardPage() {
|
|||
<SmartAlerts />
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{loading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => <SkeletonMetricCard key={i} />)
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="card p-4 space-y-3">
|
||||
<div className="skeleton w-8 h-8 rounded-md" />
|
||||
<div className="skeleton w-12 h-6" />
|
||||
<div className="skeleton w-20 h-3" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
metrics.map((m, i) => (
|
||||
<div key={i} className="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm border border-neutral-200 dark:border-slate-700 hover:shadow-md transition-shadow">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${m.color}`}>
|
||||
<m.icon size={20} />
|
||||
<div
|
||||
key={i}
|
||||
className="card card-interactive p-4 group cursor-default"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-md flex items-center justify-center mb-3 ${getAccentClasses(m.accent)}`}>
|
||||
<m.icon size={16} />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">{m.value}</p>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mt-1">{m.label}</p>
|
||||
<p className="text-2xl font-semibold text-primary tracking-tight">
|
||||
{m.value}
|
||||
</p>
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
{m.label}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Tasks Widget */}
|
||||
<TasksDueTodayWidget userId={user?.id || ''} />
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-neutral-200 dark:border-slate-700 p-6 min-h-[300px]">
|
||||
<div className="card p-5">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-bold text-neutral-800 dark:text-white">Recent Activity</h3>
|
||||
<h3 className="text-sm font-medium text-primary">Recent Activity</h3>
|
||||
<button
|
||||
onClick={() => setIsTouchModalOpen(true)}
|
||||
className="text-sm text-emerald-600 dark:text-emerald-400 hover:underline flex items-center gap-1"
|
||||
className="btn btn-ghost text-xs h-8 px-3 gap-1 text-accent hover:text-accent"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Log Activity
|
||||
Log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activityLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse flex gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
|
||||
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-600 rounded-full" />
|
||||
<div key={i} className="flex gap-3 p-3 rounded-md">
|
||||
<div className="skeleton w-8 h-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-slate-200 dark:bg-slate-600 rounded w-3/4" />
|
||||
<div className="h-3 bg-slate-200 dark:bg-slate-600 rounded w-1/2" />
|
||||
<div className="skeleton w-3/4 h-4" />
|
||||
<div className="skeleton w-1/2 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<Activity size={40} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
|
||||
<p className="text-neutral-400 dark:text-slate-500 italic">No recent activity logs</p>
|
||||
<div className="text-center py-8">
|
||||
<Activity size={32} className="mx-auto text-quaternary mb-3" />
|
||||
<p className="text-tertiary text-sm">No recent activity</p>
|
||||
<button
|
||||
onClick={() => setIsTouchModalOpen(true)}
|
||||
className="mt-3 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
className="btn btn-secondary mt-4 h-9"
|
||||
>
|
||||
Log First Activity
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
{recentActivity.map((activity: any) => (
|
||||
<div key={activity.id} className="flex items-start gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
||||
<div className={`p-2 rounded-full ${activity.type === 'WATER' ? 'bg-blue-100 dark:bg-blue-900/30' :
|
||||
activity.type === 'FEED' ? 'bg-emerald-100 dark:bg-emerald-900/30' :
|
||||
activity.type === 'IPM' ? 'bg-red-100 dark:bg-red-900/30' :
|
||||
activity.type === 'INSPECT' ? 'bg-purple-100 dark:bg-purple-900/30' :
|
||||
'bg-slate-200 dark:bg-slate-600'
|
||||
}`}>
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-start gap-3 p-3 rounded-md hover:bg-tertiary transition-colors duration-fast group"
|
||||
>
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
|
||||
${activity.type === 'WATER' ? 'bg-blue-500/10' :
|
||||
activity.type === 'FEED' ? 'bg-success-muted' :
|
||||
activity.type === 'IPM' ? 'bg-destructive-muted' :
|
||||
activity.type === 'INSPECT' ? 'bg-accent-muted' :
|
||||
'bg-tertiary'
|
||||
}
|
||||
`}>
|
||||
{getActivityIcon(activity.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
||||
{activity.type} - {activity.batch?.name || 'Unknown Batch'}
|
||||
<p className="text-sm text-primary truncate">
|
||||
<span className="font-medium">{activity.type}</span>
|
||||
<span className="text-tertiary"> — </span>
|
||||
{activity.batch?.name || 'Unknown Batch'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{activity.user?.name || 'User'} • {new Date(activity.createdAt).toLocaleString()}
|
||||
<p className="text-xs text-tertiary mt-0.5">
|
||||
{activity.user?.name || 'User'} · {new Date(activity.createdAt).toLocaleString()}
|
||||
</p>
|
||||
{activity.notes && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1 italic truncate">"{activity.notes}"</p>
|
||||
<p className="text-xs text-secondary mt-1 truncate">
|
||||
"{activity.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -180,10 +217,11 @@ export default function DashboardPage() {
|
|||
{/* Floating Action Button (Mobile) */}
|
||||
<button
|
||||
onClick={() => setIsTouchModalOpen(true)}
|
||||
className="md:hidden fixed bottom-20 right-4 w-14 h-14 bg-emerald-600 text-white rounded-full shadow-lg shadow-emerald-900/30 flex items-center justify-center hover:bg-emerald-700 active:scale-95 transition-all z-30"
|
||||
className="md:hidden fixed bottom-20 right-4 w-12 h-12 btn-primary rounded-full shadow-lg flex items-center justify-center z-30"
|
||||
style={{ backgroundColor: 'var(--color-accent)' }}
|
||||
aria-label="Log Activity"
|
||||
>
|
||||
<Plus size={28} />
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
<TouchPointModal
|
||||
|
|
@ -195,5 +233,3 @@ export default function DashboardPage() {
|
|||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText, Search, Filter, Plus, ChevronRight, Clock, User, Check, X,
|
||||
AlertCircle, Eye, Edit, Archive, Send, ThumbsUp, FolderOpen, Book,
|
||||
ClipboardList, FileCheck, HelpCircle, MoreVertical, History, Download
|
||||
FileText, Search, Plus, Clock, User, Check, X,
|
||||
AlertCircle, Eye, Edit, FolderOpen, Book,
|
||||
ClipboardList, FileCheck, HelpCircle, History, Download, Loader2
|
||||
} from 'lucide-react';
|
||||
import { documentsApi, Document, DocumentType, DocumentStatus, DocumentVersion } from '../lib/documentsApi';
|
||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
const TYPE_CONFIG: Record<DocumentType, { icon: React.ElementType; color: string; label: string }> = {
|
||||
SOP: { icon: Book, color: 'text-indigo-500 bg-indigo-50 dark:bg-indigo-900/30', label: 'Standard Operating Procedure' },
|
||||
POLICY: { icon: FileCheck, color: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30', label: 'Policy' },
|
||||
FORM: { icon: ClipboardList, color: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30', label: 'Form' },
|
||||
CHECKLIST: { icon: Check, color: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/30', label: 'Checklist' },
|
||||
GUIDE: { icon: HelpCircle, color: 'text-amber-500 bg-amber-50 dark:bg-amber-900/30', label: 'Guide' },
|
||||
OTHER: { icon: FileText, color: 'text-slate-500 bg-slate-50 dark:bg-slate-700', label: 'Document' }
|
||||
const TYPE_CONFIG: Record<DocumentType, { icon: React.ElementType; badge: string; label: string }> = {
|
||||
SOP: { icon: Book, badge: 'badge-accent', label: 'SOP' },
|
||||
POLICY: { icon: FileCheck, badge: 'badge-accent', label: 'Policy' },
|
||||
FORM: { icon: ClipboardList, badge: 'badge-accent', label: 'Form' },
|
||||
CHECKLIST: { icon: Check, badge: 'badge-success', label: 'Checklist' },
|
||||
GUIDE: { icon: HelpCircle, badge: 'badge-warning', label: 'Guide' },
|
||||
OTHER: { icon: FileText, badge: 'badge', label: 'Document' }
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<DocumentStatus, { color: string; label: string }> = {
|
||||
DRAFT: { color: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300', label: 'Draft' },
|
||||
PENDING_APPROVAL: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Approval' },
|
||||
APPROVED: { color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', label: 'Approved' },
|
||||
ARCHIVED: { color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', label: 'Archived' }
|
||||
const STATUS_CONFIG: Record<DocumentStatus, { badge: string; label: string }> = {
|
||||
DRAFT: { badge: 'badge', label: 'Draft' },
|
||||
PENDING_APPROVAL: { badge: 'badge-warning', label: 'Pending' },
|
||||
APPROVED: { badge: 'badge-success', label: 'Approved' },
|
||||
ARCHIVED: { badge: 'badge-destructive', label: 'Archived' }
|
||||
};
|
||||
|
||||
export default function DocumentsPage() {
|
||||
|
|
@ -105,40 +106,34 @@ export default function DocumentsPage() {
|
|||
}, {} as Record<string, Document[]>);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Book className="text-indigo-500" />
|
||||
SOP Library
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">Standard Operating Procedures & Compliance Documents</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium flex items-center gap-2 hover:bg-indigo-700 transition-colors">
|
||||
<Plus size={16} />
|
||||
New Document
|
||||
</button>
|
||||
</header>
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="SOP Library"
|
||||
subtitle="Standard Operating Procedures & Compliance"
|
||||
actions={
|
||||
<button className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">New Document</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Pending Acknowledgements Banner */}
|
||||
{pendingAcks.length > 0 && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
||||
<div className="card p-4 border-warning/50 bg-warning-muted">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="text-amber-600" size={20} />
|
||||
<AlertCircle className="text-warning flex-shrink-0" size={20} />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">
|
||||
{pendingAcks.length} document{pendingAcks.length > 1 ? 's require' : ' requires'} your acknowledgement
|
||||
</p>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
Please review and acknowledge these documents to remain compliant.
|
||||
<p className="font-medium text-primary text-sm">
|
||||
{pendingAcks.length} document{pendingAcks.length > 1 ? 's require' : ' requires'} acknowledgement
|
||||
</p>
|
||||
<p className="text-xs text-secondary">Review to remain compliant</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleViewDocument(pendingAcks[0])}
|
||||
className="px-3 py-1.5 bg-amber-600 text-white text-sm font-medium rounded-lg hover:bg-amber-700"
|
||||
className="btn btn-primary text-xs h-8"
|
||||
>
|
||||
Review Now
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -147,19 +142,19 @@ export default function DocumentsPage() {
|
|||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:ring-2 focus:ring-indigo-500"
|
||||
className="input w-full pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value as DocumentType | '')}
|
||||
className="px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm"
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="SOP">SOPs</option>
|
||||
|
|
@ -171,7 +166,7 @@ export default function DocumentsPage() {
|
|||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as DocumentStatus | '')}
|
||||
className="px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm"
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
|
|
@ -183,7 +178,7 @@ export default function DocumentsPage() {
|
|||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm"
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map(cat => (
|
||||
|
|
@ -195,23 +190,25 @@ export default function DocumentsPage() {
|
|||
|
||||
{/* Documents Grid */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-slate-500">Loading documents...</div>
|
||||
) : filteredDocs.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<FolderOpen className="mx-auto mb-3 text-slate-300" size={48} />
|
||||
<p className="text-slate-500">No documents found</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Create your first document or adjust filters</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : filteredDocs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title="No documents found"
|
||||
description="Create your first document or adjust filters."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedDocs).map(([category, docs]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2">
|
||||
<FolderOpen size={14} />
|
||||
<h3 className="text-xs font-medium text-tertiary uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<FolderOpen size={12} />
|
||||
{category}
|
||||
<span className="text-xs font-normal text-slate-400">({docs.length})</span>
|
||||
<span className="text-tertiary">({docs.length})</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{docs.map(doc => {
|
||||
const typeConfig = TYPE_CONFIG[doc.type];
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
|
@ -222,37 +219,35 @@ export default function DocumentsPage() {
|
|||
<div
|
||||
key={doc.id}
|
||||
onClick={() => handleViewDocument(doc)}
|
||||
className={`bg-white dark:bg-slate-800 rounded-xl border transition-all cursor-pointer hover:shadow-md ${isPending
|
||||
? 'border-amber-300 dark:border-amber-700 ring-2 ring-amber-100 dark:ring-amber-900/30'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-indigo-300 dark:hover:border-indigo-700'
|
||||
}`}
|
||||
className={`
|
||||
card card-interactive p-4 cursor-pointer
|
||||
${isPending ? 'ring-1 ring-warning/50' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${typeConfig.color}`}>
|
||||
<TypeIcon size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{doc.title}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{typeConfig.label} • v{doc.version}
|
||||
</p>
|
||||
</div>
|
||||
{isPending && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-md bg-accent-muted flex items-center justify-center flex-shrink-0">
|
||||
<TypeIcon size={16} className="text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-primary text-sm truncate">
|
||||
{doc.title}
|
||||
</h4>
|
||||
<p className="text-xs text-tertiary mt-0.5">
|
||||
{typeConfig.label} · v{doc.version}
|
||||
</p>
|
||||
</div>
|
||||
{isPending && (
|
||||
<span className="w-2 h-2 bg-warning rounded-full animate-pulse flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<Clock size={12} />
|
||||
{new Date(doc.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-subtle">
|
||||
<span className={`${statusConfig.badge} text-[10px]`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-[10px] text-tertiary">
|
||||
<Clock size={10} />
|
||||
{new Date(doc.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -266,100 +261,94 @@ export default function DocumentsPage() {
|
|||
|
||||
{/* Document Viewer Modal */}
|
||||
{selectedDoc && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" onClick={() => setSelectedDoc(null)}>
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 animate-fade-in" onClick={() => setSelectedDoc(null)}>
|
||||
<div
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden shadow-2xl flex flex-col"
|
||||
className="card max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col animate-scale-in"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700 flex justify-between items-start">
|
||||
<div className="p-4 border-b border-subtle flex justify-between items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${TYPE_CONFIG[selectedDoc.type].color}`}>
|
||||
<div className="w-10 h-10 rounded-md bg-accent-muted flex items-center justify-center">
|
||||
{(() => {
|
||||
const Icon = TYPE_CONFIG[selectedDoc.type].icon;
|
||||
return <Icon size={24} />;
|
||||
return <Icon size={20} className="text-accent" />;
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">{selectedDoc.title}</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{TYPE_CONFIG[selectedDoc.type].label} • Version {selectedDoc.version}
|
||||
<h3 className="text-lg font-semibold text-primary">{selectedDoc.title}</h3>
|
||||
<p className="text-xs text-tertiary">
|
||||
{TYPE_CONFIG[selectedDoc.type].label} · Version {selectedDoc.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowVersions(!showVersions)}
|
||||
className={`p-2 rounded-lg transition-colors ${showVersions ? 'bg-indigo-100 text-indigo-600' : 'hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400'}`}
|
||||
className={`btn btn-ghost p-2 ${showVersions ? 'text-accent bg-accent-muted' : ''}`}
|
||||
title="Version History"
|
||||
>
|
||||
<History size={18} />
|
||||
<History size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedDoc(null)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
className="btn btn-ghost p-2"
|
||||
>
|
||||
<X size={20} />
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Status Bar */}
|
||||
<div className="px-4 py-2 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CONFIG[selectedDoc.status].color}`}>
|
||||
{/* Status Bar */}
|
||||
<div className="px-4 py-2 bg-tertiary border-b border-subtle flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`${STATUS_CONFIG[selectedDoc.status].badge} text-[10px]`}>
|
||||
{STATUS_CONFIG[selectedDoc.status].label}
|
||||
</span>
|
||||
{selectedDoc.effectiveDate && (
|
||||
<span className="text-slate-500">
|
||||
<span className="text-tertiary">
|
||||
Effective: {new Date(selectedDoc.effectiveDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{selectedDoc.expiresAt && (
|
||||
<span className="text-amber-600">
|
||||
Expires: {new Date(selectedDoc.expiresAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
{selectedDoc.createdBy && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User size={14} />
|
||||
{selectedDoc.createdBy.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedDoc.createdBy && (
|
||||
<span className="flex items-center gap-1 text-tertiary">
|
||||
<User size={12} />
|
||||
{selectedDoc.createdBy.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* Main Content */}
|
||||
<div className={`flex-1 overflow-y-auto p-6 ${showVersions ? 'border-r border-slate-200 dark:border-slate-700' : ''}`}>
|
||||
<div className={`flex-1 overflow-y-auto p-6 ${showVersions ? 'border-r border-subtle' : ''}`}>
|
||||
<div
|
||||
className="prose prose-slate dark:prose-invert max-w-none"
|
||||
className="prose prose-slate dark:prose-invert max-w-none text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: selectedDoc.content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Version Sidebar */}
|
||||
{showVersions && (
|
||||
<div className="w-64 overflow-y-auto p-4 bg-slate-50 dark:bg-slate-900/50">
|
||||
<h4 className="font-medium text-slate-900 dark:text-white mb-3">Version History</h4>
|
||||
<div className="w-56 overflow-y-auto p-4 bg-tertiary">
|
||||
<h4 className="text-xs font-medium text-primary mb-3">Version History</h4>
|
||||
<div className="space-y-2">
|
||||
{versions.map(ver => (
|
||||
<div
|
||||
key={ver.id}
|
||||
className={`p-2 rounded-lg text-sm cursor-pointer ${ver.version === selectedDoc.version
|
||||
? 'bg-indigo-100 dark:bg-indigo-900/30 border border-indigo-300 dark:border-indigo-700'
|
||||
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:border-indigo-300'
|
||||
}`}
|
||||
className={`
|
||||
p-2 rounded-md text-xs cursor-pointer
|
||||
${ver.version === selectedDoc.version
|
||||
? 'bg-accent-muted ring-1 ring-accent/30'
|
||||
: 'bg-primary hover:bg-secondary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">v{ver.version}</span>
|
||||
<span className="text-xs text-slate-400">{new Date(ver.createdAt).toLocaleDateString()}</span>
|
||||
<span className="font-medium text-primary">v{ver.version}</span>
|
||||
<span className="text-tertiary">{new Date(ver.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{ver.changeNotes && (
|
||||
<p className="text-xs text-slate-500 mt-1 truncate">{ver.changeNotes}</p>
|
||||
<p className="text-tertiary mt-1 truncate">{ver.changeNotes}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -368,25 +357,23 @@ export default function DocumentsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 border-t border-slate-200 dark:border-slate-700 flex justify-between items-center">
|
||||
<div>
|
||||
{selectedDoc.requiresAck && (
|
||||
<button
|
||||
onClick={() => handleAcknowledge(selectedDoc.id)}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium flex items-center gap-2 hover:bg-emerald-700"
|
||||
>
|
||||
<Check size={16} />
|
||||
I Acknowledge This Document
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="p-2 rounded-lg border border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-500">
|
||||
<Download size={18} />
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-subtle flex justify-between items-center">
|
||||
{selectedDoc.requiresAck ? (
|
||||
<button
|
||||
onClick={() => handleAcknowledge(selectedDoc.id)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Check size={16} />
|
||||
Acknowledge
|
||||
</button>
|
||||
<button className="p-2 rounded-lg border border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-500">
|
||||
<Edit size={18} />
|
||||
) : <div />}
|
||||
<div className="flex gap-1">
|
||||
<button className="btn btn-ghost p-2">
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button className="btn btn-ghost p-2">
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Thermometer, Droplets, Wind, Sun, AlertTriangle,
|
||||
Activity, Settings, RefreshCw, ChevronRight
|
||||
Activity, Settings, RefreshCw, ChevronRight, Loader2
|
||||
} from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
interface SensorData {
|
||||
id: string;
|
||||
|
|
@ -50,24 +51,25 @@ const sensorIcons: Record<string, any> = {
|
|||
VPD: Activity,
|
||||
};
|
||||
|
||||
const sensorColors: Record<string, string> = {
|
||||
TEMPERATURE: 'text-orange-500',
|
||||
HUMIDITY: 'text-blue-500',
|
||||
CO2: 'text-purple-500',
|
||||
LIGHT_PAR: 'text-yellow-500',
|
||||
LIGHT_LUX: 'text-yellow-500',
|
||||
VPD: 'text-emerald-500',
|
||||
const sensorAccents: Record<string, 'success' | 'warning' | 'destructive' | 'accent'> = {
|
||||
TEMPERATURE: 'warning',
|
||||
HUMIDITY: 'accent',
|
||||
CO2: 'accent',
|
||||
LIGHT_PAR: 'warning',
|
||||
LIGHT_LUX: 'warning',
|
||||
VPD: 'success',
|
||||
};
|
||||
|
||||
export default function EnvironmentDashboard() {
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
||||
const [sensors, setSensors] = useState<SensorData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedRoom, setSelectedRoom] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, 60000); // Refresh every minute
|
||||
const interval = setInterval(loadData, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedRoom]);
|
||||
|
||||
|
|
@ -86,44 +88,51 @@ export default function EnvironmentDashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (value: number, min?: number, max?: number): string => {
|
||||
if (min && value < min) return 'text-blue-500';
|
||||
if (max && value > max) return 'text-red-500';
|
||||
return 'text-emerald-500';
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const getStatusClass = (value: number, min?: number, max?: number): string => {
|
||||
if (min && value < min) return 'text-accent';
|
||||
if (max && value > max) return 'text-destructive';
|
||||
return 'text-success';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="animate-spin text-emerald-500" size={32} />
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader title="Environment Monitor" subtitle="Loading..." />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
Environment Monitor
|
||||
</h1>
|
||||
<p className="text-slate-500">Real-time sensor data and alerts</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg"
|
||||
>
|
||||
<RefreshCw size={20} className="text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="Environment Monitor"
|
||||
subtitle="Real-time sensor data and alerts"
|
||||
actions={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="btn btn-ghost p-2"
|
||||
>
|
||||
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Active Alerts */}
|
||||
{dashboard?.alerts.active && dashboard.alerts.active > 0 && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div className="card p-4 border-destructive/50 bg-destructive-muted">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertTriangle className="text-red-500" size={20} />
|
||||
<span className="font-semibold text-red-700 dark:text-red-400">
|
||||
<AlertTriangle className="text-destructive" size={18} />
|
||||
<span className="font-medium text-destructive text-sm">
|
||||
{dashboard.alerts.active} Active Alert{dashboard.alerts.active > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -131,20 +140,19 @@ export default function EnvironmentDashboard() {
|
|||
{dashboard.alerts.list.slice(0, 3).map(alert => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="bg-white dark:bg-slate-800 rounded-lg p-3 flex items-center justify-between"
|
||||
className="card p-3 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
{alert.message}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-sm font-medium text-primary">{alert.message}</p>
|
||||
<p className="text-xs text-tertiary">
|
||||
{new Date(alert.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${alert.severity === 'CRITICAL' ? 'bg-red-100 text-red-700' :
|
||||
alert.severity === 'WARNING' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
<span className={`
|
||||
text-[10px] font-medium px-2 py-0.5 rounded
|
||||
${alert.severity === 'CRITICAL' ? 'badge-destructive' :
|
||||
alert.severity === 'WARNING' ? 'badge-warning' : 'badge'}
|
||||
`}>
|
||||
{alert.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -154,41 +162,30 @@ export default function EnvironmentDashboard() {
|
|||
)}
|
||||
|
||||
{/* Quick Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Object.entries(dashboard?.averages || {}).map(([type, value]) => {
|
||||
const Icon = sensorIcons[type] || Activity;
|
||||
const readings = dashboard?.readings[type] || [];
|
||||
const unit = readings[0]?.unit || '';
|
||||
const accent = sensorAccents[type] || 'accent';
|
||||
|
||||
return (
|
||||
<div
|
||||
<MetricCard
|
||||
key={type}
|
||||
className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={sensorColors[type] || 'text-slate-500'} size={20} />
|
||||
<span className="text-sm text-slate-500 capitalize">
|
||||
{type.replace('_', ' ').toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{typeof value === 'number' ? value.toFixed(1) : value}
|
||||
<span className="text-sm font-normal text-slate-500 ml-1">{unit}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
{readings.length} sensor{readings.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
icon={Icon}
|
||||
label={type.replace('_', ' ').toLowerCase()}
|
||||
value={`${typeof value === 'number' ? value.toFixed(1) : value}${unit}`}
|
||||
accent={accent}
|
||||
subtitle={`${readings.length} sensor${readings.length !== 1 ? 's' : ''}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sensors List */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
All Sensors
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<h2 className="text-sm font-medium text-primary mb-3">All Sensors</h2>
|
||||
<div className="card overflow-hidden divide-y divide-subtle">
|
||||
{sensors.map(sensor => {
|
||||
const Icon = sensorIcons[sensor.type] || Activity;
|
||||
const reading = sensor.latestReading;
|
||||
|
|
@ -196,90 +193,86 @@ export default function EnvironmentDashboard() {
|
|||
return (
|
||||
<div
|
||||
key={sensor.id}
|
||||
className="p-4 flex items-center gap-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
||||
className="p-4 flex items-center gap-4 hover:bg-tertiary transition-colors duration-fast"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${sensorColors[sensor.type]?.replace('text-', 'bg-').replace('500', '100') || 'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={sensorColors[sensor.type] || 'text-slate-500'} size={20} />
|
||||
<div className="w-9 h-9 rounded-md bg-accent-muted flex items-center justify-center">
|
||||
<Icon className="text-accent" size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900 dark:text-white">
|
||||
{sensor.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 capitalize">
|
||||
<div className="font-medium text-primary text-sm">{sensor.name}</div>
|
||||
<div className="text-xs text-tertiary capitalize">
|
||||
{sensor.type.replace('_', ' ').toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{reading ? (
|
||||
<>
|
||||
<div className={`text-lg font-semibold ${getStatusColor(reading.value, sensor.minThreshold, sensor.maxThreshold)
|
||||
}`}>
|
||||
<div className={`text-base font-semibold ${getStatusClass(reading.value, sensor.minThreshold, sensor.maxThreshold)}`}>
|
||||
{reading.value.toFixed(1)} {reading.unit}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
<div className="text-[10px] text-tertiary">
|
||||
{new Date(reading.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-slate-400">No data</span>
|
||||
<span className="text-tertiary text-xs">No data</span>
|
||||
)}
|
||||
</div>
|
||||
{sensor.alertCount > 0 && (
|
||||
<div className="bg-red-100 text-red-600 text-xs px-2 py-1 rounded">
|
||||
{sensor.alertCount}
|
||||
</div>
|
||||
<span className="badge-destructive text-[10px]">{sensor.alertCount}</span>
|
||||
)}
|
||||
<ChevronRight size={18} className="text-slate-400" />
|
||||
<ChevronRight size={14} className="text-tertiary" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sensors.length === 0 && (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
No sensors configured. Add sensors to start monitoring.
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="No sensors configured"
|
||||
description="Add sensors to start monitoring."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Profile */}
|
||||
{dashboard?.profile && (
|
||||
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Settings size={18} className="text-slate-500" />
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Settings size={16} className="text-tertiary" />
|
||||
<span className="font-medium text-primary text-sm">
|
||||
Active Profile: {dashboard.profile.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{dashboard.profile.tempMinF && dashboard.profile.tempMaxF && (
|
||||
<div>
|
||||
<span className="text-slate-500">Temperature</span>
|
||||
<div className="font-medium text-slate-900 dark:text-white">
|
||||
<span className="text-xs text-tertiary uppercase tracking-wider">Temperature</span>
|
||||
<div className="font-medium text-primary">
|
||||
{dashboard.profile.tempMinF}°F - {dashboard.profile.tempMaxF}°F
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dashboard.profile.humidityMin && dashboard.profile.humidityMax && (
|
||||
<div>
|
||||
<span className="text-slate-500">Humidity</span>
|
||||
<div className="font-medium text-slate-900 dark:text-white">
|
||||
<span className="text-xs text-tertiary uppercase tracking-wider">Humidity</span>
|
||||
<div className="font-medium text-primary">
|
||||
{dashboard.profile.humidityMin}% - {dashboard.profile.humidityMax}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dashboard.profile.co2Min && dashboard.profile.co2Max && (
|
||||
<div>
|
||||
<span className="text-slate-500">CO₂</span>
|
||||
<div className="font-medium text-slate-900 dark:text-white">
|
||||
<span className="text-xs text-tertiary uppercase tracking-wider">CO₂</span>
|
||||
<div className="font-medium text-primary">
|
||||
{dashboard.profile.co2Min} - {dashboard.profile.co2Max} ppm
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dashboard.profile.lightHours && (
|
||||
<div>
|
||||
<span className="text-slate-500">Light Cycle</span>
|
||||
<div className="font-medium text-slate-900 dark:text-white">
|
||||
<span className="text-xs text-tertiary uppercase tracking-wider">Light Cycle</span>
|
||||
<div className="font-medium text-primary">
|
||||
{dashboard.profile.lightHours}h on
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ export default function ErrorPage() {
|
|||
let is404 = false;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
// Handle 404 and other HTTP errors
|
||||
is404 = error.status === 404;
|
||||
errorTitle = is404 ? 'Page Not Found' : `${error.status} Error`;
|
||||
errorMessage = is404
|
||||
? "We couldn't find the page you're looking for. It might have been moved or deleted."
|
||||
? "We couldn't find the page you're looking for."
|
||||
: error.statusText;
|
||||
} else if (error instanceof Error) {
|
||||
// Handle logic errors
|
||||
errorTitle = 'Unexpected Error';
|
||||
errorMessage = error.message;
|
||||
} else {
|
||||
|
|
@ -24,53 +22,47 @@ export default function ErrorPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="min-h-screen bg-primary flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6 animate-in">
|
||||
<div className="relative inline-block">
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center ${is404 ? 'bg-slate-100 dark:bg-slate-800' : 'bg-red-50 dark:bg-red-900/20'
|
||||
} mx-auto mb-4 animate-scale-in`}>
|
||||
<div className={`
|
||||
w-16 h-16 rounded-lg flex items-center justify-center mx-auto mb-4
|
||||
${is404 ? 'bg-tertiary' : 'bg-destructive-muted'}
|
||||
`}>
|
||||
{is404 ? (
|
||||
<span className="text-4xl">🔍</span>
|
||||
<span className="text-3xl">🔍</span>
|
||||
) : (
|
||||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||
<AlertTriangle className="w-8 h-8 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
{!is404 && (
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center text-white font-bold text-xs animate-bounce">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2 font-mono">
|
||||
<h1 className="text-2xl font-semibold text-primary mb-2">
|
||||
{errorTitle}
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
<p className="text-secondary text-sm">
|
||||
{errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2 justify-center pt-4">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700 font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
Reload App
|
||||
<RefreshCcw size={16} />
|
||||
Reload
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-emerald-600 text-white font-medium hover:bg-emerald-700 transition-colors shadow-lg hover:shadow-emerald-500/20"
|
||||
>
|
||||
<Home size={18} />
|
||||
<Link to="/" className="btn btn-primary">
|
||||
<Home size={16} />
|
||||
Back Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t border-slate-200 dark:border-slate-800">
|
||||
<p className="text-xs text-slate-400 font-mono">
|
||||
777 WOLFPACK GROW OPS MANAGER
|
||||
<div className="pt-6 border-t border-subtle">
|
||||
<p className="text-xs text-tertiary font-mono uppercase tracking-wider">
|
||||
777 Wolfpack Grow Ops Manager
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,35 +3,35 @@ import { Home, RefreshCw, AlertTriangle, FileQuestion, ServerCrash } from 'lucid
|
|||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="w-24 h-24 mx-auto bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mb-8">
|
||||
<FileQuestion size={48} className="text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-h-screen bg-primary flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center animate-in">
|
||||
<div className="w-20 h-20 mx-auto bg-warning-muted rounded-2xl flex items-center justify-center mb-8">
|
||||
<FileQuestion size={40} className="text-warning" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl font-bold text-slate-900 dark:text-white mb-4">
|
||||
<h1 className="text-5xl font-bold text-primary mb-4 tracking-tight">
|
||||
404
|
||||
</h1>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-slate-700 dark:text-slate-300 mb-4">
|
||||
<h2 className="text-xl font-semibold text-secondary mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-8">
|
||||
<p className="text-tertiary mb-8">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-xl transition-colors"
|
||||
className="btn btn-primary h-11"
|
||||
>
|
||||
<Home size={18} />
|
||||
<Home size={16} />
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center justify-center gap-2 px-6 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="btn btn-secondary h-11"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
|
|
@ -43,37 +43,37 @@ export function NotFoundPage() {
|
|||
|
||||
export function ServerErrorPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="w-24 h-24 mx-auto bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-8">
|
||||
<ServerCrash size={48} className="text-red-600 dark:text-red-400" />
|
||||
<div className="min-h-screen bg-primary flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center animate-in">
|
||||
<div className="w-20 h-20 mx-auto bg-destructive-muted rounded-2xl flex items-center justify-center mb-8">
|
||||
<ServerCrash size={40} className="text-destructive" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl font-bold text-slate-900 dark:text-white mb-4">
|
||||
<h1 className="text-5xl font-bold text-primary mb-4 tracking-tight">
|
||||
500
|
||||
</h1>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-slate-700 dark:text-slate-300 mb-4">
|
||||
<h2 className="text-xl font-semibold text-secondary mb-4">
|
||||
Server Error
|
||||
</h2>
|
||||
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-8">
|
||||
<p className="text-tertiary mb-8">
|
||||
Something went wrong on our end. Our team has been notified.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-xl transition-colors"
|
||||
className="btn btn-primary h-11"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
<RefreshCw size={16} />
|
||||
Try Again
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center justify-center gap-2 px-6 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="btn btn-secondary h-11"
|
||||
>
|
||||
<Home size={18} />
|
||||
<Home size={16} />
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -95,7 +95,6 @@ function getErrorMessage(error: unknown): string {
|
|||
export function RouterErrorPage() {
|
||||
const error = useRouteError();
|
||||
|
||||
// Check if it's a 404
|
||||
if (isRouteErrorResponse(error) && error.status === 404) {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
|
|
@ -103,30 +102,30 @@ export function RouterErrorPage() {
|
|||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="w-24 h-24 mx-auto bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-8">
|
||||
<AlertTriangle size={48} className="text-red-600 dark:text-red-400" />
|
||||
<div className="min-h-screen bg-primary flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center animate-in">
|
||||
<div className="w-20 h-20 mx-auto bg-destructive-muted rounded-2xl flex items-center justify-center mb-8">
|
||||
<AlertTriangle size={40} className="text-destructive" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-white mb-4">
|
||||
<h1 className="text-3xl font-bold text-primary mb-4 tracking-tight">
|
||||
Oops!
|
||||
</h1>
|
||||
|
||||
<h2 className="text-xl font-semibold text-slate-700 dark:text-slate-300 mb-4">
|
||||
<h2 className="text-lg font-semibold text-secondary mb-4">
|
||||
Something went wrong
|
||||
</h2>
|
||||
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-6">
|
||||
<p className="text-tertiary mb-6">
|
||||
An unexpected error occurred. Please try again.
|
||||
</p>
|
||||
|
||||
{import.meta.env.DEV && errorMessage && (
|
||||
<details className="mb-6 text-left bg-slate-100 dark:bg-slate-800 rounded-xl p-4">
|
||||
<summary className="cursor-pointer text-sm text-slate-600 dark:text-slate-400 font-medium">
|
||||
<details className="mb-6 text-left card p-4">
|
||||
<summary className="cursor-pointer text-sm text-secondary font-medium">
|
||||
Error Details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-600 dark:text-red-400 overflow-auto max-h-32">
|
||||
<pre className="mt-2 text-xs text-destructive overflow-auto max-h-32 font-mono">
|
||||
{errorMessage}
|
||||
</pre>
|
||||
</details>
|
||||
|
|
@ -135,16 +134,16 @@ export function RouterErrorPage() {
|
|||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-xl transition-colors"
|
||||
className="btn btn-primary h-11"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
<RefreshCw size={16} />
|
||||
Try Again
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center justify-center gap-2 px-6 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="btn btn-secondary h-11"
|
||||
>
|
||||
<Home size={18} />
|
||||
<Home size={16} />
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
DollarSign, TrendingUp, TrendingDown, PieChart,
|
||||
Plus, Calendar, Filter, Download, ArrowUpRight, ArrowDownRight
|
||||
Plus, ArrowUpRight, ArrowDownRight, Loader2
|
||||
} from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
|
|
@ -68,23 +69,13 @@ export default function FinancialDashboard() {
|
|||
}).format(amount);
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
LABOR: 'bg-blue-500',
|
||||
NUTRIENTS: 'bg-green-500',
|
||||
SUPPLIES: 'bg-purple-500',
|
||||
EQUIPMENT: 'bg-orange-500',
|
||||
UTILITIES: 'bg-yellow-500',
|
||||
RENT: 'bg-red-500',
|
||||
OTHER: 'bg-slate-500'
|
||||
};
|
||||
return colors[category] || colors.OTHER;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-500"></div>
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader title="Financial Overview" subtitle="Loading..." />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -92,195 +83,176 @@ export default function FinancialDashboard() {
|
|||
const netAmount = (totals.REVENUE || 0) - (totals.EXPENSE || 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
Financial Overview
|
||||
</h1>
|
||||
<p className="text-slate-500">Track revenue, expenses, and profitability</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="Financial Overview"
|
||||
subtitle="Track revenue, expenses, and profitability"
|
||||
actions={
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg font-medium"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Transaction
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">Add Transaction</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-slate-500">Revenue</span>
|
||||
<ArrowUpRight className="text-emerald-500" size={20} />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{formatCurrency(totals.REVENUE || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-slate-500">Expenses</span>
|
||||
<ArrowDownRight className="text-red-500" size={20} />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{formatCurrency(totals.EXPENSE || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-xl p-6 border ${netAmount >= 0
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-slate-500">Net Profit/Loss</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricCard
|
||||
icon={ArrowUpRight}
|
||||
label="Revenue"
|
||||
value={formatCurrency(totals.REVENUE || 0)}
|
||||
accent="success"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ArrowDownRight}
|
||||
label="Expenses"
|
||||
value={formatCurrency(totals.EXPENSE || 0)}
|
||||
accent="destructive"
|
||||
/>
|
||||
<div className={`
|
||||
card p-4
|
||||
${netAmount >= 0 ? 'border-success/30 bg-success-muted' : 'border-destructive/30 bg-destructive-muted'}
|
||||
`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-tertiary uppercase tracking-wider">Net Profit/Loss</span>
|
||||
{netAmount >= 0 ? (
|
||||
<TrendingUp className="text-emerald-500" size={20} />
|
||||
<TrendingUp className="text-success" size={16} />
|
||||
) : (
|
||||
<TrendingDown className="text-red-500" size={20} />
|
||||
<TrendingDown className="text-destructive" size={16} />
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${netAmount >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
<div className={`text-2xl font-semibold ${netAmount >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatCurrency(netAmount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expense Breakdown */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 border border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<PieChart size={20} className="text-emerald-500" />
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Expense Breakdown */}
|
||||
<div className="card p-5">
|
||||
<h2 className="text-sm font-medium text-primary mb-4 flex items-center gap-2">
|
||||
<PieChart size={16} className="text-accent" />
|
||||
Expense Breakdown
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{categories?.breakdown.map(cat => (
|
||||
<div key={cat.category}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-slate-700 dark:text-slate-300 capitalize">
|
||||
{cat.category.toLowerCase()}
|
||||
</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">
|
||||
{formatCurrency(cat.amount)}
|
||||
</span>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-secondary capitalize">{cat.category.toLowerCase()}</span>
|
||||
<span className="font-medium text-primary">{formatCurrency(cat.amount)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="h-1.5 bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${getCategoryColor(cat.category)} rounded-full transition-all`}
|
||||
className="h-full bg-accent rounded-full transition-all duration-normal"
|
||||
style={{ width: `${cat.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">
|
||||
<div className="text-[10px] text-tertiary mt-0.5">
|
||||
{cat.percentage.toFixed(1)}% of total
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!categories || categories.breakdown.length === 0) && (
|
||||
<p className="text-slate-500 text-center py-4">No expense data</p>
|
||||
<p className="text-tertiary text-center py-4 text-sm">No expense data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Trend */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 border border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp size={20} className="text-emerald-500" />
|
||||
<div className="card p-5">
|
||||
<h2 className="text-sm font-medium text-primary mb-4 flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-accent" />
|
||||
Monthly Trend
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{profitLoss?.periods.slice(-6).map(period => (
|
||||
<div key={period.period} className="flex items-center gap-4">
|
||||
<span className="w-20 text-sm text-slate-500">{period.period}</span>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div key={period.period} className="flex items-center gap-3">
|
||||
<span className="w-16 text-xs text-tertiary">{period.period}</span>
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<div
|
||||
className="h-4 bg-emerald-500 rounded"
|
||||
className="h-3 bg-success rounded-sm"
|
||||
style={{
|
||||
width: `${Math.min((period.revenue / (profitLoss.totals.revenue || 1)) * 100, 100)}%`
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-4 bg-red-500 rounded"
|
||||
className="h-3 bg-destructive rounded-sm"
|
||||
style={{
|
||||
width: `${Math.min((period.expenses / (profitLoss.totals.expenses || 1)) * 100, 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-sm font-medium w-24 text-right ${period.net >= 0 ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}>
|
||||
<span className={`text-xs font-medium w-20 text-right ${period.net >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatCurrency(period.net)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(!profitLoss || profitLoss.periods.length === 0) && (
|
||||
<p className="text-slate-500 text-center py-4">No trend data</p>
|
||||
<p className="text-tertiary text-center py-4 text-sm">No trend data</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-4 text-xs">
|
||||
<div className="flex gap-4 mt-4 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded" />
|
||||
<span className="text-slate-500">Revenue</span>
|
||||
<div className="w-2 h-2 bg-success rounded-sm" />
|
||||
<span className="text-tertiary">Revenue</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded" />
|
||||
<span className="text-slate-500">Expenses</span>
|
||||
<div className="w-2 h-2 bg-destructive rounded-sm" />
|
||||
<span className="text-tertiary">Expenses</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
Recent Transactions
|
||||
</h2>
|
||||
<button className="text-emerald-600 hover:text-emerald-700 text-sm font-medium">
|
||||
View All
|
||||
</button>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-primary">Recent Transactions</h2>
|
||||
<button className="text-accent hover:underline text-xs">View All</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<div className="divide-y divide-subtle">
|
||||
{transactions.map(tx => (
|
||||
<div key={tx.id} className="p-4 flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${tx.type === 'REVENUE' ? 'bg-emerald-100 text-emerald-600' :
|
||||
tx.type === 'EXPENSE' ? 'bg-red-100 text-red-600' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
<DollarSign size={20} />
|
||||
<div key={tx.id} className="p-4 flex items-center gap-3">
|
||||
<div className={`
|
||||
w-8 h-8 rounded-md flex items-center justify-center
|
||||
${tx.type === 'REVENUE' ? 'bg-success-muted text-success' :
|
||||
tx.type === 'EXPENSE' ? 'bg-destructive-muted text-destructive' :
|
||||
'bg-tertiary text-secondary'}
|
||||
`}>
|
||||
<DollarSign size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{tx.description}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 flex items-center gap-2">
|
||||
{tx.category && (
|
||||
<span className="capitalize">{tx.category.toLowerCase()}</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<div className="font-medium text-primary text-sm truncate">{tx.description}</div>
|
||||
<div className="text-[10px] text-tertiary flex items-center gap-2">
|
||||
{tx.category && <span className="capitalize">{tx.category.toLowerCase()}</span>}
|
||||
<span>·</span>
|
||||
<span>{new Date(tx.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-right font-semibold ${tx.type === 'REVENUE' ? 'text-emerald-600' :
|
||||
tx.type === 'EXPENSE' ? 'text-red-600' :
|
||||
'text-slate-600'
|
||||
}`}>
|
||||
<div className={`
|
||||
text-right font-semibold text-sm
|
||||
${tx.type === 'REVENUE' ? 'text-success' :
|
||||
tx.type === 'EXPENSE' ? 'text-destructive' :
|
||||
'text-secondary'}
|
||||
`}>
|
||||
{tx.type === 'EXPENSE' ? '-' : '+'}{formatCurrency(tx.amount)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{transactions.length === 0 && (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
No transactions recorded
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={DollarSign}
|
||||
title="No transactions"
|
||||
description="Add your first transaction to get started."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
|
||||
<h1 className="text-4xl font-bold text-primary mb-6">CA Grow Ops Manager</h1>
|
||||
<p className="text-muted-foreground mb-8">Secure management for distributed operations.</p>
|
||||
<div className="flex gap-4">
|
||||
<Link to="/login">
|
||||
<Button size="lg">Login</Button>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-primary">
|
||||
<h1 className="text-3xl font-semibold text-primary mb-3">CA Grow Ops Manager</h1>
|
||||
<p className="text-secondary mb-8">Secure management for distributed operations.</p>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/login" className="btn btn-primary">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/dashboard">
|
||||
<Button variant="outline" size="lg">Dashboard Demo</Button>
|
||||
<Link to="/dashboard" className="btn btn-secondary">
|
||||
Dashboard Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { batchesApi, Batch } from '../lib/batchesApi';
|
||||
import { touchPointsApi, IPMSchedule } from '../lib/touchPointsApi';
|
||||
import { Loader2, AlertTriangle, CheckCircle, Calendar, Shield } from 'lucide-react';
|
||||
import { PageHeader } from '../components/layout/PageHeader';
|
||||
import { touchPointsApi } from '../lib/touchPointsApi';
|
||||
import { AlertTriangle, CheckCircle, Calendar, Shield, X, Loader2 } from 'lucide-react';
|
||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
export default function IPMDashboardPage() {
|
||||
const { addToast } = useToast();
|
||||
const [batches, setBatches] = useState<Batch[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [recordingBatch, setRecordingBatch] = useState<Batch | null>(null);
|
||||
const [product, setProduct] = useState('Pyganic 5.0');
|
||||
const [dosage, setDosage] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
|
@ -18,8 +21,6 @@ export default function IPMDashboardPage() {
|
|||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Fetch batches. Ideally backend filters, but MVP we filter client side for 'ACTIVE' and 'VEG'?
|
||||
// Assuming we just show all active batches and check their IPM status.
|
||||
const all = await batchesApi.getAll();
|
||||
setBatches(all.filter(b => b.status === 'ACTIVE'));
|
||||
} catch (e) {
|
||||
|
|
@ -37,14 +38,16 @@ export default function IPMDashboardPage() {
|
|||
product: 'Pyganic 5.0',
|
||||
intervalDays: 10
|
||||
});
|
||||
loadData(); // Reload to see schedule
|
||||
addToast('IPM plan started', 'success');
|
||||
loadData();
|
||||
} catch (e) {
|
||||
alert('Failed to start plan');
|
||||
addToast('Failed to start plan', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecordTreatment = async () => {
|
||||
if (!recordingBatch) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await touchPointsApi.create({
|
||||
batchId: recordingBatch.id,
|
||||
|
|
@ -55,125 +58,158 @@ export default function IPMDashboardPage() {
|
|||
});
|
||||
setRecordingBatch(null);
|
||||
loadData();
|
||||
alert('Treatment Recorded & Schedule Updated!');
|
||||
addToast('Treatment recorded!', 'success');
|
||||
} catch (e) {
|
||||
alert('Failed to record');
|
||||
addToast('Failed to record', 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="flex justify-center p-8"><Loader2 className="animate-spin" /></div>;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader title="IPM Dashboard" subtitle="Integrated pest management tracking" />
|
||||
<div className="grid gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader
|
||||
title="IPM Dashboard"
|
||||
description="Root drench schedule and integrated pest management tracking"
|
||||
icon={Shield}
|
||||
iconColor="text-emerald-600"
|
||||
subtitle="Root drench schedule and pest management tracking"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{batches.map(batch => {
|
||||
const schedule = batch.ipmSchedule; // Backend needs to include this!
|
||||
// Wait, batchesApi.getAll might not include relation by default?
|
||||
// I need to update backend getBatches to include ipmSchedule.
|
||||
// Assuming it does (I'll update controller if needed).
|
||||
{batches.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title="No active batches"
|
||||
description="Create a batch to start IPM tracking."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{batches.map(batch => {
|
||||
const schedule = batch.ipmSchedule;
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<div key={batch.id} className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700 flex justify-between items-center opacity-75">
|
||||
<div>
|
||||
<h3 className="font-bold">{batch.name}</h3>
|
||||
<p className="text-xs text-slate-500">No Active Plan</p>
|
||||
if (!schedule) {
|
||||
return (
|
||||
<div key={batch.id} className="card p-4 flex justify-between items-center opacity-75 hover:opacity-100 transition-opacity duration-fast">
|
||||
<div>
|
||||
<h3 className="font-medium text-primary text-sm">{batch.name}</h3>
|
||||
<p className="text-xs text-tertiary">No Active Plan</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStartPlan(batch.id)}
|
||||
className="btn btn-secondary text-xs h-8"
|
||||
>
|
||||
Start Plan
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nextDate = new Date(schedule.nextTreatment);
|
||||
const today = new Date();
|
||||
const diffTime = nextDate.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
let statusBadge = 'badge-success';
|
||||
let statusText = `Due in ${diffDays} days`;
|
||||
let StatusIcon = Calendar;
|
||||
|
||||
if (diffDays <= 0) {
|
||||
statusBadge = 'badge-destructive';
|
||||
statusText = diffDays === 0 ? 'DUE TODAY' : `OVERDUE (${Math.abs(diffDays)} days)`;
|
||||
StatusIcon = AlertTriangle;
|
||||
} else if (diffDays <= 3) {
|
||||
statusBadge = 'badge-warning';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={batch.id} className="card p-4">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-primary text-sm">{batch.name}</h3>
|
||||
<p className="text-xs text-tertiary">
|
||||
{schedule.intervalDays}-Day Cycle ({batch.ipmSchedule?.product || 'Pyganic'})
|
||||
</p>
|
||||
</div>
|
||||
<span className={`badge ${statusBadge} flex items-center gap-1`}>
|
||||
<StatusIcon size={10} />
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleStartPlan(batch.id)}
|
||||
className="px-3 py-1 bg-slate-200 dark:bg-slate-700 text-slate-800 dark:text-slate-200 text-xs font-bold rounded-lg"
|
||||
onClick={() => setRecordingBatch(batch)}
|
||||
className="btn btn-ghost w-full justify-center text-success hover:bg-success-muted"
|
||||
>
|
||||
Start Plan
|
||||
<CheckCircle size={16} />
|
||||
Record Treatment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nextDate = new Date(schedule.nextTreatment); // Assuming string
|
||||
const today = new Date();
|
||||
const diffTime = nextDate.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
let statusColor = 'bg-emerald-100 text-emerald-800 border-emerald-200';
|
||||
let statusText = `Due in ${diffDays} days`;
|
||||
|
||||
if (diffDays <= 0) {
|
||||
statusColor = 'bg-red-100 text-red-800 border-red-200';
|
||||
statusText = diffDays === 0 ? 'DUE TODAY' : `OVERDUE (${Math.abs(diffDays)} days)`;
|
||||
} else if (diffDays <= 3) {
|
||||
statusColor = 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={batch.id} className="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{batch.name}</h3>
|
||||
<p className="text-xs text-slate-500">Plan: {schedule.intervalDays}-Day Cycle ({batch.ipmSchedule?.product || 'Pyganic'})</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-bold border ${statusColor} flex items-center gap-1`}>
|
||||
{diffDays <= 0 ? <AlertTriangle className="w-3 h-3" /> : <Calendar className="w-3 h-3" />}
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setRecordingBatch(batch)}
|
||||
className="w-full py-3 bg-emerald-600/10 text-emerald-700 dark:text-emerald-400 font-bold rounded-lg hover:bg-emerald-600/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Record Treatment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recording Modal */}
|
||||
{recordingBatch && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-slate-800 w-full max-w-md rounded-2xl p-6 space-y-4">
|
||||
<h2 className="text-xl font-bold">Record Treatment</h2>
|
||||
<p className="text-sm text-slate-500">For {recordingBatch.name}</p>
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="card w-full max-w-md p-6 space-y-4 animate-scale-in">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">Record Treatment</h2>
|
||||
<p className="text-sm text-tertiary">{recordingBatch.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRecordingBatch(null)}
|
||||
className="p-2 rounded-md hover:bg-tertiary transition-colors duration-fast"
|
||||
>
|
||||
<X size={16} className="text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Product</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">
|
||||
Product
|
||||
</label>
|
||||
<input
|
||||
value={product}
|
||||
onChange={e => setProduct(e.target.value)}
|
||||
className="w-full p-3 rounded-lg border dark:bg-slate-700 dark:border-slate-600"
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Dosage</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">
|
||||
Dosage
|
||||
</label>
|
||||
<input
|
||||
value={dosage}
|
||||
onChange={e => setDosage(e.target.value)}
|
||||
placeholder="e.g. 1oz/gal"
|
||||
className="w-full p-3 rounded-lg border dark:bg-slate-700 dark:border-slate-600"
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setRecordingBatch(null)}
|
||||
className="flex-1 py-3 bg-slate-100 dark:bg-slate-700 rounded-xl font-bold"
|
||||
className="btn btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRecordTreatment}
|
||||
className="flex-1 py-3 bg-emerald-600 text-white rounded-xl font-bold"
|
||||
disabled={submitting}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
Confirm
|
||||
{submitting ? <Loader2 size={16} className="animate-spin" /> : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Brain, TrendingUp, AlertTriangle, Zap, Target,
|
||||
Activity, CheckCircle, XCircle, BarChart3, RefreshCw
|
||||
CheckCircle, BarChart3, RefreshCw, Loader2
|
||||
} from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
interface Prediction {
|
||||
batchId: string;
|
||||
|
|
@ -22,7 +23,7 @@ interface Anomaly {
|
|||
detectedAt: string;
|
||||
}
|
||||
|
||||
interface InsightsDashboard {
|
||||
interface InsightsDashboardData {
|
||||
predictions: {
|
||||
count: number;
|
||||
avgAccuracy: string;
|
||||
|
|
@ -43,9 +44,9 @@ interface InsightsDashboard {
|
|||
}
|
||||
|
||||
export default function InsightsDashboard() {
|
||||
const [dashboard, setDashboard] = useState<InsightsDashboard | null>(null);
|
||||
const [dashboard, setDashboard] = useState<InsightsDashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [predicting, setPredicting] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
|
@ -62,6 +63,12 @@ export default function InsightsDashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const resolveAnomaly = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/api/insights/anomalies/${id}/resolve`);
|
||||
|
|
@ -73,129 +80,112 @@ export default function InsightsDashboard() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Brain className="animate-pulse text-purple-500" size={48} />
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader title="AI Insights" subtitle="Loading..." />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const anomalyCount = dashboard?.anomalies.unresolved || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<Brain className="text-purple-500" />
|
||||
AI Insights
|
||||
</h1>
|
||||
<p className="text-slate-500">Yield predictions, anomaly detection, and performance analytics</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg"
|
||||
>
|
||||
<RefreshCw size={20} className="text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="AI Insights"
|
||||
subtitle="Predictions, anomalies, and analytics"
|
||||
actions={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="btn btn-ghost p-2"
|
||||
>
|
||||
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl p-6 text-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target size={20} />
|
||||
<span className="text-purple-100">Model Accuracy</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<MetricCard
|
||||
icon={Target}
|
||||
label="Model Accuracy"
|
||||
value={dashboard?.predictions.avgAccuracy || 'N/A'}
|
||||
subtitle={`${dashboard?.predictions.count || 0} predictions`}
|
||||
accent="accent"
|
||||
/>
|
||||
|
||||
<div className={`
|
||||
card p-4
|
||||
${anomalyCount > 0 ? 'border-warning/30 bg-warning-muted' : 'border-success/30 bg-success-muted'}
|
||||
`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className={anomalyCount > 0 ? 'text-warning' : 'text-success'} size={16} />
|
||||
<span className="text-xs text-tertiary uppercase tracking-wider">Active Anomalies</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold">
|
||||
{dashboard?.predictions.avgAccuracy || 'N/A'}
|
||||
</div>
|
||||
<div className="text-sm text-purple-200 mt-1">
|
||||
Based on {dashboard?.predictions.count || 0} predictions
|
||||
<div className={`text-2xl font-semibold ${anomalyCount > 0 ? 'text-warning' : 'text-success'}`}>
|
||||
{anomalyCount}
|
||||
</div>
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
{anomalyCount === 0 ? 'All systems normal' : 'Requires attention'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-xl p-6 ${(dashboard?.anomalies.unresolved || 0) > 0
|
||||
? 'bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800'
|
||||
: 'bg-emerald-50 dark:bg-emerald-900/20 border-2 border-emerald-200 dark:border-emerald-800'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className={
|
||||
(dashboard?.anomalies.unresolved || 0) > 0 ? 'text-amber-500' : 'text-emerald-500'
|
||||
} size={20} />
|
||||
<span className="text-slate-600 dark:text-slate-300">Active Anomalies</span>
|
||||
</div>
|
||||
<div className={`text-3xl font-bold ${(dashboard?.anomalies.unresolved || 0) > 0 ? 'text-amber-600' : 'text-emerald-600'
|
||||
}`}>
|
||||
{dashboard?.anomalies.unresolved || 0}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">
|
||||
{(dashboard?.anomalies.unresolved || 0) === 0 ? 'All systems normal' : 'Requires attention'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BarChart3 className="text-blue-500" size={20} />
|
||||
<span className="text-slate-500">Top Performers</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{dashboard?.performance.topBatches.length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">
|
||||
Batches with cost data
|
||||
</div>
|
||||
</div>
|
||||
<MetricCard
|
||||
icon={BarChart3}
|
||||
label="Top Performers"
|
||||
value={dashboard?.performance.topBatches.length || 0}
|
||||
subtitle="Batches with cost data"
|
||||
accent="accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anomalies Section */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<AlertTriangle className="text-amber-500" size={20} />
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle">
|
||||
<h2 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<AlertTriangle className="text-warning" size={16} />
|
||||
Detected Anomalies
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<div className="divide-y divide-subtle">
|
||||
{dashboard?.anomalies.recent.map(anomaly => (
|
||||
<div
|
||||
key={anomaly.id}
|
||||
className={`p-4 flex items-start gap-4 ${anomaly.isResolved ? 'bg-slate-50 dark:bg-slate-800/50' : ''
|
||||
}`}
|
||||
className={`p-4 flex items-start gap-3 ${anomaly.isResolved ? 'bg-tertiary' : ''}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${anomaly.isResolved
|
||||
? 'bg-slate-100 text-slate-400'
|
||||
: anomaly.severity === 'CRITICAL'
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-amber-100 text-amber-600'
|
||||
}`}>
|
||||
{anomaly.isResolved ? (
|
||||
<CheckCircle size={20} />
|
||||
) : (
|
||||
<AlertTriangle size={20} />
|
||||
)}
|
||||
<div className={`
|
||||
w-8 h-8 rounded-md flex items-center justify-center
|
||||
${anomaly.isResolved ? 'bg-tertiary text-tertiary' :
|
||||
anomaly.severity === 'CRITICAL' ? 'bg-destructive-muted text-destructive' :
|
||||
'bg-warning-muted text-warning'}
|
||||
`}>
|
||||
{anomaly.isResolved ? <CheckCircle size={16} /> : <AlertTriangle size={16} />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${anomaly.isResolved ? 'text-slate-400' : 'text-slate-900 dark:text-white'
|
||||
}`}>
|
||||
<span className={`font-medium text-sm ${anomaly.isResolved ? 'text-tertiary' : 'text-primary'}`}>
|
||||
{anomaly.description}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${anomaly.severity === 'CRITICAL'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
<span className={`
|
||||
text-[10px] font-medium px-1.5 py-0.5 rounded
|
||||
${anomaly.severity === 'CRITICAL' ? 'badge-destructive' : 'badge-warning'}
|
||||
`}>
|
||||
{anomaly.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{anomaly.entityType} • {anomaly.anomalyType.replace('_', ' ')} •
|
||||
<div className="text-[10px] text-tertiary mt-1">
|
||||
{anomaly.entityType} · {anomaly.anomalyType.replace('_', ' ')} ·{' '}
|
||||
{new Date(anomaly.detectedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
{!anomaly.isResolved && (
|
||||
<button
|
||||
onClick={() => resolveAnomaly(anomaly.id)}
|
||||
className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
|
|
@ -204,18 +194,18 @@ export default function InsightsDashboard() {
|
|||
))}
|
||||
{(!dashboard?.anomalies.recent || dashboard.anomalies.recent.length === 0) && (
|
||||
<div className="p-8 text-center">
|
||||
<CheckCircle className="mx-auto text-emerald-500 mb-2" size={32} />
|
||||
<p className="text-slate-500">No anomalies detected</p>
|
||||
<CheckCircle className="mx-auto text-success mb-2" size={24} />
|
||||
<p className="text-tertiary text-sm">No anomalies detected</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Performing Batches */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<TrendingUp className="text-emerald-500" size={20} />
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle">
|
||||
<h2 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<TrendingUp className="text-success" size={16} />
|
||||
Best Cost Efficiency
|
||||
</h2>
|
||||
</div>
|
||||
|
|
@ -223,7 +213,7 @@ export default function InsightsDashboard() {
|
|||
<div className="p-4">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-slate-500">
|
||||
<tr className="text-left text-[10px] text-tertiary uppercase tracking-wider">
|
||||
<th className="pb-3 font-medium">Batch</th>
|
||||
<th className="pb-3 font-medium">Cost/Gram</th>
|
||||
<th className="pb-3 font-medium">Total Cost</th>
|
||||
|
|
@ -232,35 +222,31 @@ export default function InsightsDashboard() {
|
|||
</thead>
|
||||
<tbody className="text-sm">
|
||||
{dashboard?.performance.topBatches.map((batch, idx) => (
|
||||
<tr key={batch.batchId} className="border-t border-slate-100 dark:border-slate-700">
|
||||
<tr key={batch.batchId} className="border-t border-subtle">
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${idx === 0 ? 'bg-amber-100 text-amber-700' :
|
||||
idx === 1 ? 'bg-slate-100 text-slate-700' :
|
||||
idx === 2 ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-slate-50 text-slate-500'
|
||||
}`}>
|
||||
<span className={`
|
||||
w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-semibold
|
||||
${idx === 0 ? 'bg-warning-muted text-warning' :
|
||||
idx === 1 ? 'bg-tertiary text-secondary' :
|
||||
idx === 2 ? 'bg-accent-muted text-accent' :
|
||||
'bg-tertiary text-tertiary'}
|
||||
`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-white font-medium">
|
||||
<span className="font-medium text-primary">
|
||||
{batch.batchId.substring(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 font-semibold text-emerald-600">
|
||||
${batch.costPerGram}/g
|
||||
</td>
|
||||
<td className="py-3 text-slate-600 dark:text-slate-400">
|
||||
${batch.totalCost.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-3 text-slate-600 dark:text-slate-400">
|
||||
{batch.yieldGrams?.toLocaleString() || 'N/A'}g
|
||||
</td>
|
||||
<td className="py-3 font-semibold text-success">${batch.costPerGram}/g</td>
|
||||
<td className="py-3 text-secondary">${batch.totalCost.toLocaleString()}</td>
|
||||
<td className="py-3 text-secondary">{batch.yieldGrams?.toLocaleString() || 'N/A'}g</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!dashboard?.performance.topBatches || dashboard.performance.topBatches.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 text-center text-slate-500">
|
||||
<td colSpan={4} className="py-8 text-center text-tertiary text-sm">
|
||||
No batch performance data available
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -271,43 +257,42 @@ export default function InsightsDashboard() {
|
|||
</div>
|
||||
|
||||
{/* Recent Predictions */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Zap className="text-purple-500" size={20} />
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle">
|
||||
<h2 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<Zap className="text-accent" size={16} />
|
||||
Recent Predictions
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="space-y-3">
|
||||
{dashboard?.predictions.recentAccuracy.map(pred => (
|
||||
<div
|
||||
key={pred.batchId + pred.predictedAt}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-slate-900 dark:text-white">
|
||||
Batch {pred.batchId.substring(0, 8)}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
{new Date(pred.predictedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`text-lg font-bold ${parseFloat(pred.accuracy) >= 80 ? 'text-emerald-600' :
|
||||
parseFloat(pred.accuracy) >= 60 ? 'text-amber-600' :
|
||||
'text-red-600'
|
||||
}`}>
|
||||
{pred.accuracy}
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{dashboard?.predictions.recentAccuracy.map(pred => (
|
||||
<div
|
||||
key={pred.batchId + pred.predictedAt}
|
||||
className="flex items-center justify-between p-3 bg-tertiary rounded-md"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-primary text-sm">
|
||||
Batch {pred.batchId.substring(0, 8)}
|
||||
</span>
|
||||
<p className="text-[10px] text-tertiary">
|
||||
{new Date(pred.predictedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{(!dashboard?.predictions.recentAccuracy || dashboard.predictions.recentAccuracy.length === 0) && (
|
||||
<p className="text-center text-slate-500 py-4">
|
||||
No predictions with verified accuracy yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`
|
||||
text-base font-semibold
|
||||
${parseFloat(pred.accuracy) >= 80 ? 'text-success' :
|
||||
parseFloat(pred.accuracy) >= 60 ? 'text-warning' : 'text-destructive'}
|
||||
`}>
|
||||
{pred.accuracy}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!dashboard?.predictions.recentAccuracy || dashboard.predictions.recentAccuracy.length === 0) && (
|
||||
<p className="text-center text-tertiary py-4 text-sm">
|
||||
No predictions with verified accuracy yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { DevTools } from '../components/dev/DevTools';
|
||||
import { Loader2, ArrowRight } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
|
|
@ -12,6 +13,11 @@ export default function LoginPage() {
|
|||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
document.title = '777 Wolfpack - Login';
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
|
@ -29,93 +35,118 @@ export default function LoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center p-4">
|
||||
{/* Mobile-first: Full width on mobile, max-width on tablet+ */}
|
||||
<div className="w-full max-w-md md:max-w-lg">
|
||||
{/* 777 Wolfpack Logo - Larger on tablets */}
|
||||
<div className="flex justify-center mb-6 md:mb-8">
|
||||
<img
|
||||
src="/assets/logo-777-wolfpack.jpg"
|
||||
alt="777 Wolfpack"
|
||||
className="w-24 h-24 md:w-32 md:h-32 rounded-full shadow-2xl shadow-blue-500/50 ring-4 ring-blue-400/30"
|
||||
/>
|
||||
<div className="min-h-screen bg-primary flex items-center justify-center p-4">
|
||||
{/* Background subtle pattern */}
|
||||
<div
|
||||
className="fixed inset-0 opacity-[0.02] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main container */}
|
||||
<div className="w-full max-w-[380px] animate-in">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative group">
|
||||
<img
|
||||
src="/assets/logo-777-wolfpack.jpg"
|
||||
alt="777 Wolfpack"
|
||||
className="w-20 h-20 rounded-2xl shadow-lg transition-transform duration-slow ease-out-expo group-hover:scale-105"
|
||||
/>
|
||||
{/* Subtle glow on hover */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent opacity-0 group-hover:opacity-10 transition-opacity duration-normal blur-xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl overflow-hidden border border-slate-200 dark:border-slate-700">
|
||||
{/* Card */}
|
||||
<div className="card p-8 animate-slide-up" style={{ animationDelay: '50ms' }}>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-emerald-600 to-emerald-700 p-6 md:p-8 text-center">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||
CA GROW OPS
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-emerald-100 mt-2 text-sm md:text-base">
|
||||
777 Wolfpack Edition
|
||||
<p className="text-secondary text-sm mt-2">
|
||||
Sign in to 777 Wolfpack
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form - Touch-friendly spacing */}
|
||||
<form onSubmit={handleSubmit} className="p-6 md:p-8 space-y-6">
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm md:text-base rounded-lg border border-red-200 dark:border-red-800">
|
||||
{error}
|
||||
<div className="flex items-center gap-3 p-3 bg-destructive-muted rounded-md animate-scale-in">
|
||||
<div className="w-1.5 h-1.5 bg-destructive rounded-full" />
|
||||
<span className="text-sm text-destructive">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Input - Touch-friendly */}
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm md:text-base font-medium text-slate-700 dark:text-slate-300">
|
||||
Email Address
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="w-full px-4 py-3 md:py-4 text-base rounded-lg border-2 border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all"
|
||||
placeholder="admin@runfoo.run"
|
||||
className="input w-full"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Input - Touch-friendly */}
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm md:text-base font-medium text-slate-700 dark:text-slate-300">
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="w-full px-4 py-3 md:py-4 text-base rounded-lg border-2 border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all"
|
||||
className="input w-full"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button - Large touch target */}
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full min-h-[56px] py-4 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white text-base md:text-lg font-bold rounded-lg shadow-lg shadow-emerald-900/20 transition-all active:scale-[0.98] disabled:cursor-not-allowed"
|
||||
className="btn btn-primary w-full h-11 text-sm font-medium group"
|
||||
>
|
||||
{isLoading ? 'Accessing...' : 'Access Facility'}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>Signing in...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Continue</span>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="transition-transform duration-fast group-hover:translate-x-0.5"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-slate-50 dark:bg-slate-900/50 p-4 text-center text-xs md:text-sm text-slate-500 dark:text-slate-400">
|
||||
Authorized Personnel Only • 777 Wolfpack
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Text - Mobile friendly */}
|
||||
<p className="text-center mt-6 text-sm md:text-base text-slate-300">
|
||||
Need help? Contact your facility manager
|
||||
{/* Footer */}
|
||||
<p className="text-center mt-6 text-xs text-tertiary animate-in" style={{ animationDelay: '150ms' }}>
|
||||
Authorized personnel only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dev Tools - quick user switching */}
|
||||
{/* Dev Tools */}
|
||||
<DevTools />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { BarChart3, TrendingUp, Users, Leaf } from 'lucide-react';
|
||||
import { analyticsApi, YieldAnalytics, TaskAnalytics } from '../lib/analyticsApi';
|
||||
import { PageHeader, EmptyState, MetricCard, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [yieldData, setYieldData] = useState<YieldAnalytics | null>(null);
|
||||
|
|
@ -19,76 +20,86 @@ export default function ReportsPage() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
<header className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Reports</h2>
|
||||
<p className="text-sm text-slate-500">Analytics & Performance Metrics</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<PageHeader title="Reports" subtitle="Analytics & Performance Metrics" />
|
||||
|
||||
{/* Tab Selector */}
|
||||
<div className="flex gap-2 bg-white dark:bg-slate-800 p-1 rounded-xl border border-slate-200 dark:border-slate-700 w-fit">
|
||||
<div className="card p-1 inline-flex gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('yield')}
|
||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors flex items-center gap-2 ${activeTab === 'yield'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
className={`
|
||||
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast
|
||||
flex items-center gap-2
|
||||
${activeTab === 'yield'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-secondary hover:text-primary hover:bg-tertiary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Leaf size={18} />
|
||||
<Leaf size={16} />
|
||||
Yield Reports
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors flex items-center gap-2 ${activeTab === 'tasks'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
className={`
|
||||
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast
|
||||
flex items-center gap-2
|
||||
${activeTab === 'tasks'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-secondary hover:text-primary hover:bg-tertiary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<BarChart3 size={18} />
|
||||
<BarChart3 size={16} />
|
||||
Task Analytics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-slate-500">Loading reports...</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'yield' && yieldData && (
|
||||
<div className="space-y-6">
|
||||
{/* Strain Performance */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<TrendingUp className="text-emerald-500" />
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle">
|
||||
<h3 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-success" />
|
||||
Strain Performance
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">Average yield per plant by strain</p>
|
||||
<p className="text-xs text-tertiary mt-1">Average yield per plant by strain</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{yieldData.byStrain.length === 0 ? (
|
||||
<p className="text-slate-500 text-center py-8 italic">No yield data recorded yet. Log weight during harvest stages.</p>
|
||||
<p className="text-tertiary text-center py-8 text-sm">
|
||||
No yield data recorded yet. Log weight during harvest stages.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{yieldData.byStrain.sort((a, b) => b.avgGramsPerPlant - a.avgGramsPerPlant).map((strain, i) => (
|
||||
<div key={strain.strain} className="flex items-center gap-4">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 flex items-center justify-center font-bold text-sm">
|
||||
<div className={`
|
||||
w-7 h-7 rounded-md flex items-center justify-center font-semibold text-xs
|
||||
${i === 0 ? 'bg-warning-muted text-warning' : 'bg-tertiary text-secondary'}
|
||||
`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-medium text-slate-900 dark:text-white">{strain.strain}</span>
|
||||
<span className="font-bold text-emerald-600">{strain.avgGramsPerPlant}g / plant</span>
|
||||
<span className="text-sm font-medium text-primary">{strain.strain}</span>
|
||||
<span className="text-sm font-semibold text-success">{strain.avgGramsPerPlant}g / plant</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="h-1.5 bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full"
|
||||
className="h-full bg-success rounded-full transition-all duration-slow"
|
||||
style={{ width: `${Math.min(100, (strain.avgGramsPerPlant / 100) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{strain.batchCount} batch{strain.batchCount > 1 ? 'es' : ''} • {strain.totalGrams.toLocaleString()}g total • {strain.totalPlants} plants
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
{strain.batchCount} batch{strain.batchCount > 1 ? 'es' : ''} · {strain.totalGrams.toLocaleString()}g total · {strain.totalPlants} plants
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -99,34 +110,36 @@ export default function ReportsPage() {
|
|||
</div>
|
||||
|
||||
{/* Batch Yields Table */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Batch Yields</h3>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle">
|
||||
<h3 className="text-sm font-medium text-primary">Batch Yields</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 dark:bg-slate-700/50">
|
||||
<thead className="bg-secondary">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-slate-500 dark:text-slate-400 uppercase">Batch</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-slate-500 dark:text-slate-400 uppercase">Strain</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-slate-500 dark:text-slate-400 uppercase">Plants</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-slate-500 dark:text-slate-400 uppercase">Total Yield</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-slate-500 dark:text-slate-400 uppercase">g/Plant</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Batch</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Strain</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-tertiary uppercase tracking-wider">Plants</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-tertiary uppercase tracking-wider">Total</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-tertiary uppercase tracking-wider">g/Plant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<tbody className="divide-y divide-subtle">
|
||||
{yieldData.byBatch.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500 italic">No batch yield data available.</td>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-tertiary text-sm">
|
||||
No batch yield data available.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
yieldData.byBatch.map(batch => (
|
||||
<tr key={batch.batchId} className="hover:bg-slate-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-white">{batch.batchName}</td>
|
||||
<td className="px-4 py-3 text-slate-600 dark:text-slate-300">{batch.strain}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-600 dark:text-slate-300">{batch.plantCount}</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-slate-900 dark:text-white">{batch.totalGrams.toLocaleString()}g</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-emerald-600">{batch.gramsPerPlant}g</td>
|
||||
<tr key={batch.batchId} className="hover:bg-tertiary transition-colors duration-fast">
|
||||
<td className="px-4 py-3 text-sm font-medium text-primary">{batch.batchName}</td>
|
||||
<td className="px-4 py-3 text-sm text-secondary">{batch.strain}</td>
|
||||
<td className="px-4 py-3 text-sm text-secondary text-right">{batch.plantCount}</td>
|
||||
<td className="px-4 py-3 text-sm font-semibold text-primary text-right">{batch.totalGrams.toLocaleString()}g</td>
|
||||
<td className="px-4 py-3 text-sm font-semibold text-success text-right">{batch.gramsPerPlant}g</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
|
|
@ -140,35 +153,23 @@ export default function ReportsPage() {
|
|||
{activeTab === 'tasks' && taskData && (
|
||||
<div className="space-y-6">
|
||||
{/* Task Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<p className="text-2xl font-bold text-emerald-600">{taskData.summary.completed}</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Completed</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<p className="text-2xl font-bold text-amber-600">{taskData.summary.pending}</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Pending</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<p className="text-2xl font-bold text-blue-600">{taskData.summary.inProgress}</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">In Progress</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<p className="text-2xl font-bold text-red-600">{taskData.summary.overdue}</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Overdue</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard icon={BarChart3} label="Completed" value={taskData.summary.completed} accent="success" />
|
||||
<MetricCard icon={BarChart3} label="Pending" value={taskData.summary.pending} accent="warning" />
|
||||
<MetricCard icon={BarChart3} label="In Progress" value={taskData.summary.inProgress} accent="accent" />
|
||||
<MetricCard icon={BarChart3} label="Overdue" value={taskData.summary.overdue} accent="destructive" />
|
||||
</div>
|
||||
|
||||
{/* Completion Rate */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-4">Completion Rate</h3>
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-primary mb-4">Completion Rate</h3>
|
||||
<div className="h-2 bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
||||
className="h-full bg-success rounded-full transition-all duration-slow"
|
||||
style={{ width: `${taskData.summary.total > 0 ? (taskData.summary.completed / taskData.summary.total * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
<p className="text-xs text-tertiary mt-2">
|
||||
{taskData.summary.total > 0
|
||||
? `${Math.round(taskData.summary.completed / taskData.summary.total * 100)}% complete`
|
||||
: 'No tasks created yet'}
|
||||
|
|
@ -176,33 +177,37 @@ export default function ReportsPage() {
|
|||
</div>
|
||||
|
||||
{/* Staff Leaderboard */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Users className="text-blue-500" />
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle">
|
||||
<h3 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<Users size={16} className="text-accent" />
|
||||
Staff Leaderboard (This Week)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{taskData.completedByUserThisWeek.length === 0 ? (
|
||||
<p className="text-slate-500 text-center py-8 italic">No tasks completed this week.</p>
|
||||
<p className="text-tertiary text-center py-8 text-sm">
|
||||
No tasks completed this week.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{taskData.completedByUserThisWeek.sort((a, b) => b.completedCount - a.completedCount).map((user, i) => (
|
||||
<div key={user.userId} className="flex items-center gap-4 p-3 bg-slate-50 dark:bg-slate-700/30 rounded-lg">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${i === 0 ? 'bg-amber-100 text-amber-700' :
|
||||
i === 1 ? 'bg-slate-200 text-slate-600' :
|
||||
i === 2 ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
<div key={user.userId} className="flex items-center gap-3 p-3 rounded-md hover:bg-tertiary transition-colors duration-fast">
|
||||
<div className={`
|
||||
w-7 h-7 rounded-md flex items-center justify-center font-semibold text-xs
|
||||
${i === 0 ? 'bg-warning-muted text-warning' :
|
||||
i === 1 ? 'bg-tertiary text-secondary' :
|
||||
i === 2 ? 'bg-warning-muted/50 text-warning' :
|
||||
'bg-tertiary text-tertiary'}
|
||||
`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium text-slate-900 dark:text-white">{user.userName}</span>
|
||||
<span className="text-sm font-medium text-primary">{user.userName}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-blue-600">{user.completedCount}</span>
|
||||
<span className="text-xs text-slate-500 ml-1">tasks</span>
|
||||
<span className="text-lg font-semibold text-accent">{user.completedCount}</span>
|
||||
<span className="text-xs text-tertiary ml-1">tasks</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { useState, useEffect } from 'react';
|
|||
import { Shield, Plus, Edit2, Trash2, Users } from 'lucide-react';
|
||||
import { rolesApi, Role } from '../lib/rolesApi';
|
||||
import RoleModal from '../components/roles/RoleModal';
|
||||
import { PageHeader, EmptyState, ActionButton, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<Role | undefined>(undefined);
|
||||
|
||||
|
|
@ -13,11 +15,14 @@ export default function RolesPage() {
|
|||
}, []);
|
||||
|
||||
const loadRoles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await rolesApi.getAll();
|
||||
setRoles(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -48,81 +53,102 @@ export default function RolesPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6 pb-20">
|
||||
<header className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Shield className="text-emerald-600" />
|
||||
Roles & Permissions
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm">
|
||||
Manage staff access levels and permissions
|
||||
</p>
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="Roles & Permissions"
|
||||
subtitle="Manage staff access levels"
|
||||
actions={
|
||||
<button onClick={handleCreate} className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">New Role</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 dark:bg-slate-700 text-white rounded-lg hover:bg-slate-800"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>New Role</span>
|
||||
</button>
|
||||
</header>
|
||||
) : roles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title="No roles configured"
|
||||
description="Create your first role to manage permissions."
|
||||
action={
|
||||
<button onClick={handleCreate} className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
Create Role
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{roles.map(role => (
|
||||
<div key={role.id} className="card card-interactive p-4 flex flex-col h-full group">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-primary text-sm flex items-center gap-2">
|
||||
{role.name}
|
||||
{role.isSystem && (
|
||||
<span className="badge text-[10px]">System</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs text-tertiary mt-1 truncate">
|
||||
{role.description || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-md bg-accent-muted flex items-center justify-center flex-shrink-0">
|
||||
<Shield size={14} className="text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{roles.map(role => (
|
||||
<div key={role.id} className="bg-white dark:bg-slate-800 p-6 rounded-xl border border-slate-200 dark:border-slate-700 flex flex-col h-full hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
{role.name}
|
||||
{role.isSystem && (
|
||||
<span className="bg-slate-100 dark:bg-slate-700 text-slate-500 text-xs px-2 py-0.5 rounded-full font-medium">System</span>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-2">
|
||||
Access
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.keys(role.permissions).slice(0, 4).map(res => (
|
||||
<span key={res} className="badge-success text-[10px]">
|
||||
{res}
|
||||
</span>
|
||||
))}
|
||||
{Object.keys(role.permissions).length > 4 && (
|
||||
<span className="badge text-[10px]">
|
||||
+{Object.keys(role.permissions).length - 4}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{role.description || 'No description'}</p>
|
||||
{Object.keys(role.permissions).length === 0 && (
|
||||
<span className="text-xs text-tertiary">No permissions</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
|
||||
<Shield size={20} className="text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-2">Access Summary</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.keys(role.permissions).map(res => (
|
||||
<span key={res} className="text-xs bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 px-2 py-1 rounded capitalize">
|
||||
{res}
|
||||
</span>
|
||||
))}
|
||||
{Object.keys(role.permissions).length === 0 && <span className="text-xs text-slate-400 italic">No specific permissions</span>}
|
||||
<div className="border-t border-subtle mt-4 pt-3 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1 text-xs text-tertiary">
|
||||
<Users size={12} />
|
||||
<span>{role._count?.users || 0}</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-fast">
|
||||
<ActionButton
|
||||
icon={Edit2}
|
||||
label="Edit"
|
||||
onClick={() => handleEdit(role)}
|
||||
variant="accent"
|
||||
/>
|
||||
{!role.isSystem && (
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
label="Delete"
|
||||
onClick={() => handleDelete(role)}
|
||||
variant="destructive"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 mt-4 pt-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1 text-sm text-slate-500">
|
||||
<Users size={14} />
|
||||
<span>{role._count?.users || 0} Users</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(role)}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg text-slate-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
{!role.isSystem && (
|
||||
<button
|
||||
onClick={() => handleDelete(role)}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg text-slate-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RoleModal
|
||||
isOpen={isModalOpen}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Home, Plus } from 'lucide-react';
|
||||
import { Home, Plus, Thermometer, Droplets } from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { PageHeader, PageHeaderButton } from '../components/layout/PageHeader';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
export default function RoomsPage() {
|
||||
const { isManager } = usePermissions();
|
||||
|
|
@ -25,88 +25,113 @@ export default function RoomsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const getRoomTypeColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
VEG: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
FLOWER: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
DRY: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
CURE: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
MOTHER: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
TRIM: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400',
|
||||
const getRoomTypeAccent = (type: string): 'default' | 'accent' | 'success' | 'warning' => {
|
||||
const accents: Record<string, 'default' | 'accent' | 'success' | 'warning'> = {
|
||||
VEG: 'success',
|
||||
FLOWER: 'accent',
|
||||
DRY: 'warning',
|
||||
CURE: 'warning',
|
||||
MOTHER: 'accent',
|
||||
TRIM: 'default',
|
||||
};
|
||||
return colors[type] || 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300';
|
||||
return accents[type] || 'default';
|
||||
};
|
||||
|
||||
const getBadgeClass = (type: string) => {
|
||||
const accent = getRoomTypeAccent(type);
|
||||
const classes = {
|
||||
default: 'badge',
|
||||
accent: 'badge-accent',
|
||||
success: 'badge-success',
|
||||
warning: 'badge-warning',
|
||||
};
|
||||
return classes[accent];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader
|
||||
title="Cultivation Rooms"
|
||||
description="Manage grow rooms and monitor their status"
|
||||
icon={Home}
|
||||
iconColor="text-green-600"
|
||||
title="Rooms"
|
||||
subtitle={isLoading ? 'Loading...' : `${rooms.length} cultivation rooms`}
|
||||
actions={
|
||||
isManager && (
|
||||
<PageHeaderButton variant="primary">
|
||||
<Plus size={18} />
|
||||
Add Room
|
||||
</PageHeaderButton>
|
||||
<button className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
<span className="hidden md:inline">Add Room</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Home}
|
||||
title="No rooms configured"
|
||||
description="Set up your first cultivation room to start tracking."
|
||||
action={
|
||||
isManager && (
|
||||
<button className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
Create First Room
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{rooms.map(room => (
|
||||
<div
|
||||
key={room.id}
|
||||
className="bg-white dark:bg-slate-800 p-5 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-md transition-shadow"
|
||||
className="card card-interactive p-4 group"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="font-semibold text-lg text-slate-900 dark:text-white">
|
||||
<h3 className="font-medium text-primary text-sm">
|
||||
{room.name?.replace('[DEMO] ', '')}
|
||||
</h3>
|
||||
<span className={`px-2 py-1 text-[10px] rounded font-bold uppercase tracking-wider ${getRoomTypeColor(room.type)}`}>
|
||||
<span className={getBadgeClass(room.type)}>
|
||||
{room.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-slate-600 dark:text-slate-400">
|
||||
<span>Size:</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">{room.sqft?.toLocaleString()} sqft</span>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-tertiary">Size</span>
|
||||
<span className="text-primary font-medium">
|
||||
{room.sqft?.toLocaleString()} sqft
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600 dark:text-slate-400">
|
||||
<span>Capacity:</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">{room.capacity || '—'} plants</span>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-tertiary">Capacity</span>
|
||||
<span className="text-primary font-medium">
|
||||
{room.capacity || '—'} plants
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600 dark:text-slate-400">
|
||||
<span>Active Batches:</span>
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">{room.batches?.length || 0}</span>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-tertiary">Active Batches</span>
|
||||
<span className="text-accent font-medium">
|
||||
{room.batches?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{room.targetTemp && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-100 dark:border-slate-700 flex gap-4 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>🌡️ {room.targetTemp}°F</span>
|
||||
<span>💧 {room.targetHumidity}%</span>
|
||||
<div className="mt-4 pt-3 border-t border-subtle flex gap-4 text-xs text-tertiary">
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer size={12} />
|
||||
{room.targetTemp}°F
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets size={12} />
|
||||
{room.targetHumidity}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{rooms.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-16 bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-dashed border-slate-300 dark:border-slate-600">
|
||||
<Home size={48} className="text-slate-300 dark:text-slate-600 mb-4" />
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-4">No rooms found</p>
|
||||
{isManager && (
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700">
|
||||
<Plus size={18} />
|
||||
Create First Room
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Settings, Shield, ChevronRight, Sun, Moon, Globe, Type,
|
||||
Eye, Volume2, Bell, Minimize2, RotateCcw
|
||||
Eye, Volume2, Bell, Minimize2, RotateCcw, Monitor
|
||||
} from 'lucide-react';
|
||||
import { usePreferences } from '../context/PreferencesContext';
|
||||
import { PageHeader } from '../components/ui/LinearPrimitives';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -25,241 +26,209 @@ export default function SettingsPage() {
|
|||
}
|
||||
];
|
||||
|
||||
// Toggle switch component
|
||||
const Toggle = ({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={onChange}
|
||||
className={`
|
||||
relative w-10 h-6 rounded-full transition-colors duration-fast
|
||||
${checked ? 'bg-accent' : 'bg-tertiary'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
absolute top-1 left-1 w-4 h-4 bg-white rounded-full
|
||||
transition-transform duration-fast
|
||||
${checked ? 'translate-x-4' : ''}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto pb-20 space-y-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<div className="max-w-2xl mx-auto pb-20 space-y-6 animate-in">
|
||||
<PageHeader title="Settings" subtitle="Customize your experience" />
|
||||
|
||||
{/* Theme Selection */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 px-1">
|
||||
<section className="card p-4">
|
||||
<h2 className="text-xs font-medium text-tertiary uppercase tracking-wider mb-4">
|
||||
{t('settings.theme')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['light', 'dark', 'system'] as const).map(theme => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => setPreference('theme', theme)}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all
|
||||
${preferences.theme === theme
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
|
||||
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
aria-pressed={preferences.theme === theme}
|
||||
>
|
||||
{theme === 'light' && <Sun className="text-amber-500" size={24} />}
|
||||
{theme === 'dark' && <Moon className="text-blue-500" size={24} />}
|
||||
{theme === 'system' && <Settings className="text-slate-500" size={24} />}
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">
|
||||
{t(`settings.themes.${theme}`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['light', 'dark', 'system'] as const).map(theme => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => setPreference('theme', theme)}
|
||||
className={`
|
||||
flex flex-col items-center gap-2 p-4 rounded-md border transition-all duration-fast
|
||||
${preferences.theme === theme
|
||||
? 'border-accent bg-accent-muted'
|
||||
: 'border-default hover:border-strong'
|
||||
}
|
||||
`}
|
||||
aria-pressed={preferences.theme === theme}
|
||||
>
|
||||
{theme === 'light' && <Sun className="text-warning" size={20} />}
|
||||
{theme === 'dark' && <Moon className="text-accent" size={20} />}
|
||||
{theme === 'system' && <Monitor className="text-secondary" size={20} />}
|
||||
<span className="text-xs font-medium text-primary capitalize">
|
||||
{t(`settings.themes.${theme}`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Language Selection */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 px-1">
|
||||
<section className="card p-4">
|
||||
<h2 className="text-xs font-medium text-tertiary uppercase tracking-wider mb-4">
|
||||
{t('settings.language')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Globe className="text-slate-400" size={20} />
|
||||
<select
|
||||
value={preferences.language}
|
||||
onChange={(e) => setPreference('language', e.target.value as 'en' | 'es')}
|
||||
className="flex-1 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg px-3 py-2 text-slate-900 dark:text-white"
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="text-tertiary" size={18} />
|
||||
<select
|
||||
value={preferences.language}
|
||||
onChange={(e) => setPreference('language', e.target.value as 'en' | 'es')}
|
||||
className="input flex-1"
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Accessibility Options */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 px-1">
|
||||
<section className="card divide-y divide-subtle">
|
||||
<h2 className="text-xs font-medium text-tertiary uppercase tracking-wider p-4">
|
||||
{t('settings.accessibility')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 divide-y divide-slate-100 dark:divide-slate-700">
|
||||
|
||||
{/* Font Size */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Type className="text-slate-400" size={20} />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 dark:text-white">Font Size</div>
|
||||
<div className="text-xs text-slate-500">Adjust text size for readability</div>
|
||||
</div>
|
||||
{/* Font Size */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Type className="text-tertiary" size={18} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-primary">Font Size</div>
|
||||
<div className="text-xs text-tertiary">Adjust text size</div>
|
||||
</div>
|
||||
<select
|
||||
value={preferences.fontSize}
|
||||
onChange={(e) => setPreference('fontSize', e.target.value as any)}
|
||||
className="bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg px-3 py-2 text-slate-900 dark:text-white text-sm"
|
||||
aria-label="Font size"
|
||||
>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="large">Large</option>
|
||||
<option value="xlarge">Extra Large</option>
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
value={preferences.fontSize}
|
||||
onChange={(e) => setPreference('fontSize', e.target.value as any)}
|
||||
className="input w-32 h-9 text-sm"
|
||||
aria-label="Font size"
|
||||
>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="large">Large</option>
|
||||
<option value="xlarge">Extra Large</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* High Contrast */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Eye className="text-slate-400" size={20} />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 dark:text-white">High Contrast</div>
|
||||
<div className="text-xs text-slate-500">Increase color contrast for visibility</div>
|
||||
</div>
|
||||
{/* High Contrast */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Eye className="text-tertiary" size={18} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-primary">High Contrast</div>
|
||||
<div className="text-xs text-tertiary">Increase color contrast</div>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={preferences.highContrast}
|
||||
onClick={() => setPreference('highContrast', !preferences.highContrast)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors
|
||||
${preferences.highContrast ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform
|
||||
${preferences.highContrast ? 'translate-x-6' : ''}`}
|
||||
/>
|
||||
<span className="sr-only">Toggle high contrast</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={preferences.highContrast}
|
||||
onChange={() => setPreference('highContrast', !preferences.highContrast)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reduced Motion */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Minimize2 className="text-slate-400" size={20} />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 dark:text-white">Reduced Motion</div>
|
||||
<div className="text-xs text-slate-500">Minimize animations and transitions</div>
|
||||
</div>
|
||||
{/* Reduced Motion */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Minimize2 className="text-tertiary" size={18} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-primary">Reduced Motion</div>
|
||||
<div className="text-xs text-tertiary">Minimize animations</div>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={preferences.reducedMotion}
|
||||
onClick={() => setPreference('reducedMotion', !preferences.reducedMotion)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors
|
||||
${preferences.reducedMotion ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform
|
||||
${preferences.reducedMotion ? 'translate-x-6' : ''}`}
|
||||
/>
|
||||
<span className="sr-only">Toggle reduced motion</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={preferences.reducedMotion}
|
||||
onChange={() => setPreference('reducedMotion', !preferences.reducedMotion)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sound Effects */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="text-slate-400" size={20} />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 dark:text-white">Sound Effects</div>
|
||||
<div className="text-xs text-slate-500">Play sounds for notifications</div>
|
||||
</div>
|
||||
{/* Sound Effects */}
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="text-tertiary" size={18} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-primary">Sound Effects</div>
|
||||
<div className="text-xs text-tertiary">Play notification sounds</div>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={preferences.soundEnabled}
|
||||
onClick={() => setPreference('soundEnabled', !preferences.soundEnabled)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors
|
||||
${preferences.soundEnabled ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform
|
||||
${preferences.soundEnabled ? 'translate-x-6' : ''}`}
|
||||
/>
|
||||
<span className="sr-only">Toggle sound effects</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={preferences.soundEnabled}
|
||||
onChange={() => setPreference('soundEnabled', !preferences.soundEnabled)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notifications */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 px-1">
|
||||
{t('settings.notifications')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="text-slate-400" size={20} />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 dark:text-white">Push Notifications</div>
|
||||
<div className="text-xs text-slate-500">Receive alerts for important updates</div>
|
||||
</div>
|
||||
<section className="card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="text-tertiary" size={18} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-primary">Push Notifications</div>
|
||||
<div className="text-xs text-tertiary">Receive alerts for updates</div>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={preferences.notificationsEnabled}
|
||||
onClick={() => setPreference('notificationsEnabled', !preferences.notificationsEnabled)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors
|
||||
${preferences.notificationsEnabled ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform
|
||||
${preferences.notificationsEnabled ? 'translate-x-6' : ''}`}
|
||||
/>
|
||||
<span className="sr-only">Toggle notifications</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={preferences.notificationsEnabled}
|
||||
onChange={() => setPreference('notificationsEnabled', !preferences.notificationsEnabled)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Admin Sections */}
|
||||
{sections.map((section, idx) => (
|
||||
<section key={idx}>
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 px-1">
|
||||
Admin Panel
|
||||
<section key={idx} className="card overflow-hidden">
|
||||
<h2 className="text-xs font-medium text-tertiary uppercase tracking-wider p-4 border-b border-subtle">
|
||||
Admin
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
{section.items.map((item, itemIdx) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={itemIdx}
|
||||
onClick={item.action}
|
||||
className={`w-full flex items-center gap-4 p-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors text-left ${itemIdx !== section.items.length - 1 ? 'border-b border-slate-100 dark:border-slate-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center text-emerald-600 dark:text-emerald-400">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-slate-900 dark:text-white">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={18} className="text-slate-400" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{section.items.map((item, itemIdx) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={itemIdx}
|
||||
onClick={item.action}
|
||||
className={`
|
||||
w-full flex items-center gap-4 p-4 hover:bg-tertiary transition-colors duration-fast text-left
|
||||
${itemIdx !== section.items.length - 1 ? 'border-b border-subtle' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-md bg-accent-muted flex items-center justify-center text-accent">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-primary">{item.label}</div>
|
||||
<div className="text-xs text-tertiary">{item.description}</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-tertiary" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Reset Button */}
|
||||
<section>
|
||||
<button
|
||||
onClick={resetPreferences}
|
||||
className="w-full flex items-center justify-center gap-2 p-4 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 rounded-xl border border-red-200 dark:border-red-800 transition-colors"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
Reset to Default Settings
|
||||
</button>
|
||||
</section>
|
||||
<button
|
||||
onClick={resetPreferences}
|
||||
className="w-full flex items-center justify-center gap-2 p-4 text-destructive hover:bg-destructive-muted rounded-md border border-destructive/20 transition-colors duration-fast"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
<span className="text-sm font-medium">Reset to Defaults</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
Filter,
|
||||
AlertCircle,
|
||||
Check,
|
||||
ExternalLink
|
||||
Plus, Search, ShoppingCart, Package, Filter,
|
||||
AlertCircle, ExternalLink, X, Loader2
|
||||
} from 'lucide-react';
|
||||
import { suppliesApi, SupplyItem, SupplyCategory } from '../lib/suppliesApi';
|
||||
import InfoTooltip from '../components/InfoTooltip';
|
||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
export default function SuppliesPage() {
|
||||
const { addToast } = useToast();
|
||||
const [items, setItems] = useState<SupplyItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<'all' | 'shopping'>('all');
|
||||
|
|
@ -20,7 +16,6 @@ export default function SuppliesPage() {
|
|||
const [categoryFilter, setCategoryFilter] = useState<SupplyCategory | 'ALL'>('ALL');
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [newItem, setNewItem] = useState({
|
||||
name: '',
|
||||
category: 'OTHER' as SupplyCategory,
|
||||
|
|
@ -50,7 +45,6 @@ export default function SuppliesPage() {
|
|||
};
|
||||
|
||||
const handleQuantityAdjust = async (id: string, adjustment: number) => {
|
||||
// Optimistic update
|
||||
setItems(current => current.map(item => {
|
||||
if (item.id === id) {
|
||||
return { ...item, quantity: Math.max(0, item.quantity + adjustment) };
|
||||
|
|
@ -61,7 +55,6 @@ export default function SuppliesPage() {
|
|||
try {
|
||||
await suppliesApi.adjustQuantity(id, adjustment);
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
loadItems();
|
||||
}
|
||||
};
|
||||
|
|
@ -72,19 +65,13 @@ export default function SuppliesPage() {
|
|||
await suppliesApi.create(newItem);
|
||||
setIsAddModalOpen(false);
|
||||
setNewItem({
|
||||
name: '',
|
||||
category: 'OTHER',
|
||||
quantity: 0,
|
||||
minThreshold: 1,
|
||||
unit: 'each',
|
||||
location: '',
|
||||
vendor: '',
|
||||
productUrl: '',
|
||||
notes: ''
|
||||
name: '', category: 'OTHER', quantity: 0, minThreshold: 1,
|
||||
unit: 'each', location: '', vendor: '', productUrl: '', notes: ''
|
||||
});
|
||||
addToast('Item created', 'success');
|
||||
loadItems();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
addToast('Failed to create item', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -92,297 +79,206 @@ export default function SuppliesPage() {
|
|||
try {
|
||||
const updated = await suppliesApi.markOrdered(id);
|
||||
setItems(current => current.map(item => item.id === id ? updated : item));
|
||||
addToast('Marked as ordered', 'success');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
addToast('Failed to update', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Derived state
|
||||
const shoppingListCount = items.filter(i => i.quantity <= i.minThreshold).length;
|
||||
|
||||
const filteredItems = items.filter(item => {
|
||||
const matchesSearch = item.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(item.location && item.location.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(item.vendor && item.vendor.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
const matchesCategory = categoryFilter === 'ALL' || item.category === categoryFilter;
|
||||
const matchesView = view === 'all' || (view === 'shopping' && item.quantity <= item.minThreshold);
|
||||
|
||||
return matchesSearch && matchesCategory && matchesView;
|
||||
});
|
||||
|
||||
const categories: SupplyCategory[] = ['FILTER', 'CLEANING', 'PPE', 'OFFICE', 'BATHROOM', 'KITCHEN', 'MAINTENANCE', 'OTHER'];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6 pb-20">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
{view === 'shopping' ? '🛒 Shopping List' : '📦 Storage & Inventory'}
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm">
|
||||
{view === 'shopping'
|
||||
? `${filteredItems.length} items need attention`
|
||||
: 'Manage facility supplies and track usage'}
|
||||
<InfoTooltip content="Track inventory levels, set reorder points, and generate shopping lists automatically." />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setView('all')}
|
||||
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-sm font-medium transition-colors ${view === 'all'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
Inventory
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('shopping')}
|
||||
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-sm font-medium transition-colors relative ${view === 'shopping'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
Shopping List
|
||||
{shoppingListCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs flex items-center justify-center rounded-full">
|
||||
{shoppingListCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title={view === 'shopping' ? 'Shopping List' : 'Storage & Inventory'}
|
||||
subtitle={view === 'shopping'
|
||||
? `${filteredItems.length} items need attention`
|
||||
: 'Manage facility supplies'
|
||||
}
|
||||
actions={
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 px-4 py-2 bg-slate-900 dark:bg-slate-700 text-white rounded-lg text-sm font-medium hover:bg-slate-800"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Item
|
||||
<span className="hidden sm:inline">Add Item</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="card p-1 inline-flex gap-1">
|
||||
<button
|
||||
onClick={() => setView('all')}
|
||||
className={`
|
||||
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast
|
||||
${view === 'all' ? 'bg-accent text-white' : 'text-secondary hover:text-primary hover:bg-tertiary'}
|
||||
`}
|
||||
>
|
||||
Inventory
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('shopping')}
|
||||
className={`
|
||||
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast relative
|
||||
${view === 'shopping' ? 'bg-accent text-white' : 'text-secondary hover:text-primary hover:bg-tertiary'}
|
||||
`}
|
||||
>
|
||||
Shopping List
|
||||
{shoppingListCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-destructive text-white text-[10px] flex items-center justify-center rounded-full">
|
||||
{shoppingListCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search supplies, vendors..."
|
||||
placeholder="Search supplies..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg bg-slate-50 dark:bg-slate-900 border-none focus:ring-2 focus:ring-emerald-500 text-slate-900 dark:text-white"
|
||||
className="input w-full pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="absolute inset-y-0 left-0 w-4 bg-gradient-to-r from-white dark:from-slate-800 to-transparent pointer-events-none z-10" />
|
||||
<div className="absolute inset-y-0 right-0 w-4 bg-gradient-to-l from-white dark:from-slate-800 to-transparent pointer-events-none z-10" />
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-1 px-1 snap-x">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
||||
<button
|
||||
onClick={() => setCategoryFilter('ALL')}
|
||||
className={`badge flex-shrink-0 ${categoryFilter === 'ALL' ? 'badge-accent' : ''}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
onClick={() => setCategoryFilter('ALL')}
|
||||
className={`snap-start flex-shrink-0 px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-all border ${categoryFilter === 'ALL'
|
||||
? 'bg-emerald-600 border-emerald-600 text-white shadow-md'
|
||||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-emerald-500'
|
||||
}`}
|
||||
key={cat}
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={`badge flex-shrink-0 capitalize ${categoryFilter === cat ? 'badge-accent' : ''}`}
|
||||
>
|
||||
All
|
||||
{cat.toLowerCase()}
|
||||
</button>
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={`snap-start flex-shrink-0 px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-all border ${categoryFilter === cat
|
||||
? 'bg-emerald-600 border-emerald-600 text-white shadow-md'
|
||||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-emerald-500'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item List */}
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 bg-white dark:bg-slate-800 rounded-xl border border-dashed border-slate-300 dark:border-slate-700 text-center">
|
||||
<div className="w-16 h-16 bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center mb-4">
|
||||
<Package size={32} className="text-slate-400 dark:text-slate-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
|
||||
{search || categoryFilter !== 'ALL' ? 'No items found' : 'No supplies yet'}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400 max-w-sm mb-6">
|
||||
{search || categoryFilter !== 'ALL'
|
||||
? "Try adjusting your search or filters to find what you're looking for."
|
||||
: "Get started by adding your first supply item to track inventory."}
|
||||
</p>
|
||||
{(search || categoryFilter !== 'ALL') ? (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setCategoryFilter('ALL'); }}
|
||||
className="text-emerald-600 dark:text-emerald-400 font-medium hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="bg-emerald-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
Add First Item
|
||||
</button>
|
||||
)}
|
||||
{/* Items */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title={search || categoryFilter !== 'ALL' ? 'No items found' : 'No supplies yet'}
|
||||
description={search || categoryFilter !== 'ALL'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'Add your first item to track inventory.'}
|
||||
action={
|
||||
search || categoryFilter !== 'ALL' ? (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setCategoryFilter('ALL'); }}
|
||||
className="text-accent hover:underline text-sm"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => setIsAddModalOpen(true)} className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
Add First Item
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`bg-white dark:bg-slate-800 rounded-xl border ${item.quantity <= item.minThreshold
|
||||
? 'border-red-300 dark:border-red-900/50 ring-1 ring-red-100 dark:ring-red-900/20'
|
||||
: 'border-slate-200 dark:border-slate-700'
|
||||
} shadow-sm transition-all hover:shadow-md overflow-hidden`}
|
||||
className={`
|
||||
card p-4 flex flex-col
|
||||
${item.quantity <= item.minThreshold ? 'ring-1 ring-destructive/30' : ''}
|
||||
`}
|
||||
>
|
||||
{/* ==================== MOBILE LAYOUT (Row) ==================== */}
|
||||
<div className="flex md:hidden p-3 gap-3">
|
||||
{/* Left: Info */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
||||
<h3 className="font-bold text-slate-900 dark:text-white flex items-center gap-2 text-sm truncate">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-primary text-sm flex items-center gap-2">
|
||||
{item.name}
|
||||
{item.quantity <= item.minThreshold && (
|
||||
<AlertCircle size={14} className="text-red-500 flex-shrink-0" />
|
||||
<AlertCircle size={14} className="text-destructive flex-shrink-0" />
|
||||
)}
|
||||
</h3>
|
||||
<div className="text-[10px] text-slate-500 dark:text-slate-400 mt-1 flex flex-wrap gap-1.5">
|
||||
<span className="bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded capitalize">
|
||||
{item.category.toLowerCase()}
|
||||
</span>
|
||||
{item.location && (
|
||||
<span className="bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded flex items-center gap-1 max-w-[80px] truncate">
|
||||
📍 {item.location}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
<span className="badge text-[10px] capitalize">{item.category.toLowerCase()}</span>
|
||||
{item.location && <span className="badge text-[10px]">📍 {item.location}</span>}
|
||||
</div>
|
||||
{/* Mobile Order Button (Full width row below info if needed, or inline) */}
|
||||
{item.quantity <= item.minThreshold && (
|
||||
<button
|
||||
onClick={() => handleMarkOrdered(item.id)}
|
||||
className={`mt-2 flex items-center justify-center gap-1.5 h-7 rounded text-xs font-medium transition-colors w-full ${item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
|
||||
? 'bg-slate-100 dark:bg-slate-700 text-slate-500'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString() ? 'Ordered' : 'Add to List'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Quantity & Incrementor */}
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold tabular-nums text-slate-900 dark:text-white leading-none">
|
||||
{item.quantity}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500">{item.unit}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-900 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => handleQuantityAdjust(item.id, -1)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 shadow-sm disabled:opacity-50"
|
||||
disabled={item.quantity <= 0}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuantityAdjust(item.id, 1)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 shadow-sm"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-semibold text-primary tabular-nums">{item.quantity}</div>
|
||||
<div className="text-xs text-tertiary">{item.unit}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ==================== DESKTOP LAYOUT (Card) ==================== */}
|
||||
<div className="hidden md:flex flex-col h-full p-5 relative">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900 dark:text-white flex items-center gap-2 text-lg">
|
||||
{item.name}
|
||||
{item.quantity <= item.minThreshold && (
|
||||
<AlertCircle size={18} className="text-red-500" />
|
||||
)}
|
||||
</h3>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-2 flex flex-wrap gap-2">
|
||||
<span className="bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded capitalize">
|
||||
{item.category.toLowerCase()}
|
||||
</span>
|
||||
{item.location && (
|
||||
<span className="bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded flex items-center gap-1">
|
||||
📍 {item.location}
|
||||
</span>
|
||||
)}
|
||||
{item.vendor && (
|
||||
<span className="bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded flex items-center gap-1">
|
||||
🏪 {item.vendor}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
{item.quantity}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto pt-3 border-t border-subtle flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 bg-tertiary rounded-md p-0.5">
|
||||
<button
|
||||
onClick={() => handleQuantityAdjust(item.id, -1)}
|
||||
disabled={item.quantity <= 0}
|
||||
className="w-7 h-7 rounded flex items-center justify-center bg-primary text-secondary hover:text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuantityAdjust(item.id, 1)}
|
||||
className="w-7 h-7 rounded flex items-center justify-center bg-primary text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4 border-t border-slate-100 dark:border-slate-700 flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-900 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => handleQuantityAdjust(item.id, -1)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 shadow-sm disabled:opacity-50 hover:bg-slate-50"
|
||||
disabled={item.quantity <= 0}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuantityAdjust(item.id, 1)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 shadow-sm hover:bg-slate-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{item.quantity <= item.minThreshold && (
|
||||
<div className="flex-1 flex gap-2">
|
||||
{item.productUrl && (
|
||||
<a
|
||||
href={item.productUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 flex items-center justify-center gap-2 h-10 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 text-sm font-medium hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
Buy
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleMarkOrdered(item.id)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 h-10 rounded-lg text-sm font-medium transition-colors ${item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
|
||||
? 'bg-slate-100 dark:bg-slate-700 text-slate-500'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100'
|
||||
}`}
|
||||
{item.quantity <= item.minThreshold && (
|
||||
<div className="flex-1 flex gap-1">
|
||||
{item.productUrl && (
|
||||
<a
|
||||
href={item.productUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost text-xs h-7 flex-1 text-accent hover:bg-accent-muted"
|
||||
>
|
||||
{item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString() ? 'Ordered' : 'Add to List'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink size={12} />
|
||||
Buy
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleMarkOrdered(item.id)}
|
||||
className={`
|
||||
btn text-xs h-7 flex-1
|
||||
${item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
|
||||
? 'btn-secondary'
|
||||
: 'btn-ghost text-destructive hover:bg-destructive-muted'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
|
||||
? 'Ordered'
|
||||
: 'Order'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -392,27 +288,27 @@ export default function SuppliesPage() {
|
|||
{/* Mobile FAB */}
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
className="md:hidden fixed bottom-20 right-4 w-14 h-14 bg-emerald-600 text-white rounded-full shadow-lg flex items-center justify-center z-40 hover:bg-emerald-700 active:scale-95 transition-all"
|
||||
className="md:hidden fixed bottom-20 right-4 w-14 h-14 bg-accent text-white rounded-full shadow-lg flex items-center justify-center z-40 hover:bg-accent/90 active:scale-95 transition-all"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
{/* Add Item Modal */}
|
||||
{/* Add Modal */}
|
||||
{isAddModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-800 z-10">
|
||||
<h2 className="text-xl font-bold dark:text-white">Add New Item</h2>
|
||||
<button onClick={() => setIsAddModalOpen(false)} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
✕
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 animate-fade-in">
|
||||
<div className="card w-full max-w-md max-h-[90vh] overflow-y-auto animate-scale-in">
|
||||
<div className="p-4 border-b border-subtle flex justify-between items-center sticky top-0 bg-primary z-10">
|
||||
<h2 className="text-lg font-semibold text-primary">Add New Item</h2>
|
||||
<button onClick={() => setIsAddModalOpen(false)} className="p-2 hover:bg-tertiary rounded-md">
|
||||
<X size={16} className="text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="p-6 space-y-4">
|
||||
<form onSubmit={handleCreate} className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Item Name</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Item Name</label>
|
||||
<input
|
||||
required
|
||||
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||
className="input w-full"
|
||||
value={newItem.name}
|
||||
onChange={e => setNewItem({ ...newItem, name: e.target.value })}
|
||||
placeholder="e.g. 5 Gallon Pots"
|
||||
|
|
@ -421,9 +317,9 @@ export default function SuppliesPage() {
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Category</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Category</label>
|
||||
<select
|
||||
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||
className="input w-full"
|
||||
value={newItem.category}
|
||||
onChange={e => setNewItem({ ...newItem, category: e.target.value as any })}
|
||||
>
|
||||
|
|
@ -431,9 +327,9 @@ export default function SuppliesPage() {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Unit</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Unit</label>
|
||||
<input
|
||||
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||
className="input w-full"
|
||||
value={newItem.unit}
|
||||
onChange={e => setNewItem({ ...newItem, unit: e.target.value })}
|
||||
placeholder="box, each..."
|
||||
|
|
@ -443,19 +339,19 @@ export default function SuppliesPage() {
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Current Qty</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Current Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||
className="input w-full"
|
||||
value={newItem.quantity}
|
||||
onChange={e => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Min Threshold</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Min Threshold</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||
className="input w-full"
|
||||
value={newItem.minThreshold}
|
||||
onChange={e => setNewItem({ ...newItem, minThreshold: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
|
|
@ -464,18 +360,18 @@ export default function SuppliesPage() {
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Vendor (Optional)</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Vendor</label>
|
||||
<input
|
||||
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||
className="input w-full"
|
||||
value={newItem.vendor}
|
||||
onChange={e => setNewItem({ ...newItem, vendor: e.target.value })}
|
||||
placeholder="e.g. Amazon"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Product URL</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Product URL</label>
|
||||
<input
|
||||
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||
className="input w-full"
|
||||
value={newItem.productUrl}
|
||||
onChange={e => setNewItem({ ...newItem, productUrl: e.target.value })}
|
||||
placeholder="https://..."
|
||||
|
|
@ -484,19 +380,16 @@ export default function SuppliesPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Location</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Location</label>
|
||||
<input
|
||||
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||
className="input w-full"
|
||||
value={newItem.location}
|
||||
onChange={e => setNewItem({ ...newItem, location: e.target.value })}
|
||||
placeholder="e.g. Storage Room A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl shadow-lg mt-4"
|
||||
>
|
||||
<button type="submit" className="btn btn-primary w-full h-12 mt-4">
|
||||
Create Item
|
||||
</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { useState, useEffect } from 'react';
|
|||
import { Plus, Edit2, Trash2, FileText, Clock, Package } from 'lucide-react';
|
||||
import { taskTemplatesApi, TaskTemplate } from '../lib/taskTemplatesApi';
|
||||
import TaskTemplateModal from '../components/TaskTemplateModal';
|
||||
import { PageHeader, EmptyState, ActionButton, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
export default function TaskTemplatesPage() {
|
||||
const [templates, setTemplates] = useState<TaskTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<TaskTemplate | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
|
|
@ -13,11 +15,14 @@ export default function TaskTemplatesPage() {
|
|||
}, []);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await taskTemplatesApi.getAll();
|
||||
setTemplates(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -42,81 +47,79 @@ export default function TaskTemplatesPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6 pb-20">
|
||||
<header className="flex justify-between items-center sticky top-0 bg-slate-50 dark:bg-slate-900 z-10 py-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<FileText className="text-emerald-600" />
|
||||
SOP Managers
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm">
|
||||
Manage standardized task templates
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 shadow-lg transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>New Template</span>
|
||||
</button>
|
||||
</header>
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="SOP Templates"
|
||||
subtitle="Standardized task templates"
|
||||
actions={
|
||||
<button onClick={handleCreate} className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">New Template</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{templates.length === 0 ? (
|
||||
<div className="col-span-full text-center py-20 bg-white dark:bg-slate-800 rounded-xl border border-dashed border-slate-300 dark:border-slate-700">
|
||||
<div className="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center mb-4">
|
||||
<FileText size={32} className="text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">No templates yet</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2">Create your first SOP template to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
templates.map(template => (
|
||||
<div key={template.id} className="bg-white dark:bg-slate-800 p-6 rounded-xl border border-slate-200 dark:border-slate-700 hover:shadow-lg transition-all group relative">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900 dark:text-white mb-1">{template.title}</h3>
|
||||
<span className="inline-block px-2 py-1 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 text-xs font-bold rounded uppercase">
|
||||
{template.roomType}
|
||||
</span>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="No templates yet"
|
||||
description="Create your first SOP template to get started."
|
||||
action={
|
||||
<button onClick={handleCreate} className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
Create Template
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{templates.map(template => (
|
||||
<div key={template.id} className="card card-interactive p-4 flex flex-col h-full group">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-primary text-sm">{template.title}</h3>
|
||||
<span className="badge mt-1">{template.roomType}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-fast">
|
||||
<ActionButton
|
||||
icon={Edit2}
|
||||
label="Edit"
|
||||
onClick={() => handleEdit(template)}
|
||||
className="p-2 text-slate-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
variant="accent"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
label="Delete"
|
||||
onClick={() => handleDelete(template.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mb-4 line-clamp-3">
|
||||
<p className="text-xs text-tertiary mb-4 line-clamp-2 flex-1">
|
||||
{template.description || "No description provided."}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400 pt-4 border-t border-slate-100 dark:border-slate-700">
|
||||
<div className="flex items-center gap-4 text-xs text-tertiary pt-3 border-t border-subtle">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
<Clock size={12} />
|
||||
{template.estimatedMinutes} mins
|
||||
</span>
|
||||
{template.materials && template.materials.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Package size={14} />
|
||||
<Package size={12} />
|
||||
{template.materials.length} Items
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<TaskTemplateModal
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Calendar, List, Plus, CheckCircle, Clock, AlertTriangle, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { Calendar, List, Plus, CheckCircle, Clock, RefreshCw, User, MapPin } from 'lucide-react';
|
||||
import { tasksApi, Task } from '../lib/tasksApi';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import CreateTaskModal from '../components/tasks/CreateTaskModal';
|
||||
import CompleteTaskModal from '../components/tasks/CompleteTaskModal';
|
||||
import { SkeletonList } from '../components/ui/Skeleton';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
||||
import { SwipeableRow } from '../components/ui/SwipeableRow';
|
||||
import { usePersistedFilter } from '../hooks/usePersistedState';
|
||||
import { hapticFeedback } from '../lib/haptics';
|
||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
export default function TasksPage() {
|
||||
const { user } = useAuth();
|
||||
|
|
@ -60,33 +59,43 @@ export default function TasksPage() {
|
|||
addToast('Task completed!', 'success');
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'URGENT':
|
||||
return <span className="badge badge-destructive animate-pulse">Urgent</span>;
|
||||
case 'HIGH':
|
||||
return <span className="badge badge-warning">High</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh} disabled={loading}>
|
||||
<div className="max-w-6xl mx-auto space-y-6 pb-20">
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="Tasks & Schedule"
|
||||
subtitle="Manage daily operations and assignments"
|
||||
icon={Calendar}
|
||||
title="Tasks"
|
||||
subtitle="Manage operations and assignments"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="p-2 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
|
||||
className="btn btn-ghost"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={20} className={refreshing ? 'animate-spin' : ''} />
|
||||
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = '/tasks/templates'}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<List size={16} />
|
||||
<span className="hidden md:inline">Manage SOPs</span>
|
||||
<span className="hidden md:inline">SOPs</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 dark:bg-slate-700 text-white rounded-lg hover:bg-slate-800"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span className="hidden md:inline">New Task</span>
|
||||
|
|
@ -101,97 +110,115 @@ export default function TasksPage() {
|
|||
onSuccess={handleTaskCreated}
|
||||
/>
|
||||
|
||||
{/* View & Filter Tabs */}
|
||||
<div className="flex justify-between items-center bg-white dark:bg-slate-800 p-2 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex gap-1">
|
||||
{/* Filter & View Toggle */}
|
||||
<div className="flex justify-between items-center card p-1">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setFilter('mine')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${filter === 'mine' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300' : 'text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700'}`}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast ${filter === 'mine'
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-secondary hover:text-primary hover:bg-tertiary'
|
||||
}`}
|
||||
>
|
||||
My Tasks
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${filter === 'all' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300' : 'text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700'}`}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast ${filter === 'all'
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-secondary hover:text-primary hover:bg-tertiary'
|
||||
}`}
|
||||
>
|
||||
All Tasks
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-l border-slate-200 dark:border-slate-700 pl-2 ml-2">
|
||||
<div className="flex gap-0.5 border-l border-default pl-2">
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`p-2 rounded-lg transition-colors ${view === 'list' ? 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-white' : 'text-slate-400'}`}
|
||||
className={`p-2 rounded-md transition-colors duration-fast ${view === 'list' ? 'bg-tertiary text-primary' : 'text-tertiary'
|
||||
}`}
|
||||
>
|
||||
<List size={20} />
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('calendar')}
|
||||
className={`p-2 rounded-lg transition-colors ${view === 'calendar' ? 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-white' : 'text-slate-400'}`}
|
||||
className={`p-2 rounded-md transition-colors duration-fast ${view === 'calendar' ? 'bg-tertiary text-primary' : 'text-tertiary'
|
||||
}`}
|
||||
>
|
||||
<Calendar size={20} />
|
||||
<Calendar size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<SkeletonList items={5} />
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="card p-4 space-y-2">
|
||||
<div className="skeleton w-3/4 h-5" />
|
||||
<div className="skeleton w-1/2 h-4" />
|
||||
</div>
|
||||
))
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-dashed border-slate-300 dark:border-slate-700">
|
||||
<Calendar size={48} className="mx-auto text-slate-300 dark:text-slate-600 mb-4" />
|
||||
<p className="text-slate-500 dark:text-slate-400 font-medium">No tasks found</p>
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">
|
||||
{filter === 'mine' ? 'You have no assigned tasks' : 'No tasks have been created yet'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
className="mt-4 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title="No tasks found"
|
||||
description={filter === 'mine' ? 'You have no assigned tasks' : 'No tasks have been created yet'}
|
||||
action={
|
||||
<button onClick={() => setIsCreateOpen(true)} className="btn btn-primary">
|
||||
<Plus size={16} />
|
||||
Create Task
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
<SwipeableRow
|
||||
key={task.id}
|
||||
onSwipeRight={task.status !== 'COMPLETED' ? () => setCompletionTask(task) : undefined}
|
||||
rightAction={task.status !== 'COMPLETED' ? {
|
||||
icon: <CheckCircle size={24} />,
|
||||
color: 'bg-emerald-500',
|
||||
icon: <CheckCircle size={20} />,
|
||||
color: 'bg-success',
|
||||
label: 'Complete'
|
||||
} : undefined}
|
||||
disabled={task.status === 'COMPLETED'}
|
||||
>
|
||||
<div className="p-4 border border-slate-200 dark:border-slate-700 flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className={`font-bold text-lg ${task.status === 'COMPLETED' ? 'text-slate-500 line-through' : 'text-slate-900 dark:text-white'}`}>
|
||||
<div className="card p-4 flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className={`font-medium text-sm ${task.status === 'COMPLETED'
|
||||
? 'text-tertiary line-through'
|
||||
: 'text-primary'
|
||||
}`}>
|
||||
{task.title}
|
||||
</h3>
|
||||
{task.priority === 'HIGH' && (
|
||||
<span className="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded font-bold uppercase">High</span>
|
||||
)}
|
||||
{task.priority === 'URGENT' && (
|
||||
<span className="px-2 py-0.5 bg-red-600 text-white text-xs rounded font-bold uppercase animate-pulse">Urgent</span>
|
||||
)}
|
||||
{getPriorityBadge(task.priority)}
|
||||
{task.status === 'COMPLETED' && (
|
||||
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 text-xs rounded font-medium">Done</span>
|
||||
<span className="badge badge-success">Done</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4 text-xs text-tertiary flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
Due: {task.dueDate ? new Date(task.dueDate).toLocaleDateString() : 'No date'}
|
||||
<Clock size={12} />
|
||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : 'No date'}
|
||||
</span>
|
||||
{task.room && <span>📍 {task.room.name}</span>}
|
||||
{task.assignee && <span>👤 {task.assignee.name}</span>}
|
||||
</p>
|
||||
{task.room && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin size={12} />
|
||||
{task.room.name}
|
||||
</span>
|
||||
)}
|
||||
{task.assignee && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User size={12} />
|
||||
{task.assignee.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{task.notes && task.status === 'COMPLETED' && (
|
||||
<p className="text-xs text-slate-400 mt-2 bg-slate-50 dark:bg-slate-900/50 p-2 rounded">
|
||||
📝 {task.notes}
|
||||
<p className="text-xs text-secondary mt-2 p-2 bg-tertiary rounded-md">
|
||||
{task.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -199,10 +226,10 @@ export default function TasksPage() {
|
|||
{task.status !== 'COMPLETED' && (
|
||||
<button
|
||||
onClick={() => setCompletionTask(task)}
|
||||
className="w-10 h-10 rounded-full border-2 border-slate-200 dark:border-slate-600 flex items-center justify-center hover:bg-emerald-50 dark:hover:bg-emerald-900/20 hover:border-emerald-500 transition-colors group"
|
||||
className="w-9 h-9 rounded-full border border-default flex items-center justify-center hover:bg-success-muted hover:border-success transition-colors duration-fast group flex-shrink-0"
|
||||
title="Mark as complete"
|
||||
>
|
||||
<CheckCircle size={20} className="text-slate-300 group-hover:text-emerald-500" />
|
||||
<CheckCircle size={16} className="text-tertiary group-hover:text-success" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Clock, LogIn, LogOut } from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { PageHeader } from '../components/ui/LinearPrimitives';
|
||||
|
||||
export default function TimeclockPage() {
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [status, setStatus] = useState<'CLOCKED_OUT' | 'CLOCKED_IN'>('CLOCKED_OUT');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/timeclock/logs');
|
||||
setLogs(data);
|
||||
// Determine status
|
||||
const active = data.find((l: any) => !l.endTime);
|
||||
setStatus(active ? 'CLOCKED_IN' : 'CLOCKED_OUT');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -35,17 +40,23 @@ export default function TimeclockPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl mx-auto">
|
||||
<header className="text-center">
|
||||
<h2 className="text-3xl font-bold text-neutral-800">Time Clock</h2>
|
||||
<p className="text-neutral-500 mt-2">{new Date().toLocaleDateString()} {new Date().toLocaleTimeString()}</p>
|
||||
</header>
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-in">
|
||||
<PageHeader
|
||||
title="Time Clock"
|
||||
subtitle={new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||
/>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-lg border border-neutral-200 text-center">
|
||||
<div className="mb-8 p-4 bg-neutral-50 rounded-lg inline-block">
|
||||
<span className="text-sm font-bold text-neutral-400 uppercase tracking-widest">Current Status</span>
|
||||
<div className={`text-2xl font-bold mt-1 ${status === 'CLOCKED_IN' ? 'text-emerald-600' : 'text-neutral-600'}`}>
|
||||
{status.replace('_', ' ')}
|
||||
{/* Status Card */}
|
||||
<div className="card p-8 text-center">
|
||||
<div className="mb-6">
|
||||
<span className="text-xs font-medium text-tertiary uppercase tracking-wider">
|
||||
Current Status
|
||||
</span>
|
||||
<div className={`
|
||||
text-xl font-semibold mt-2
|
||||
${status === 'CLOCKED_IN' ? 'text-success' : 'text-secondary'}
|
||||
`}>
|
||||
{status === 'CLOCKED_IN' ? 'Clocked In' : 'Clocked Out'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -53,42 +64,79 @@ export default function TimeclockPage() {
|
|||
<button
|
||||
onClick={() => handleClock('in')}
|
||||
disabled={status === 'CLOCKED_IN'}
|
||||
className="w-40 h-40 rounded-full font-bold text-xl flex items-center justify-center transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-emerald-600 text-white hover:bg-emerald-700 hover:scale-105 shadow-xl shadow-emerald-900/20"
|
||||
className={`
|
||||
w-32 h-32 rounded-full font-semibold text-sm flex flex-col items-center justify-center gap-2
|
||||
transition-all duration-normal
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
${status !== 'CLOCKED_IN'
|
||||
? 'bg-success text-white hover:scale-105 shadow-lg'
|
||||
: 'bg-tertiary text-tertiary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
CLOCK IN
|
||||
<LogIn size={24} />
|
||||
Clock In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClock('out')}
|
||||
disabled={status === 'CLOCKED_OUT'}
|
||||
className="w-40 h-40 rounded-full font-bold text-xl flex items-center justify-center transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-red-600 text-white hover:bg-red-700 hover:scale-105 shadow-xl shadow-red-900/20"
|
||||
className={`
|
||||
w-32 h-32 rounded-full font-semibold text-sm flex flex-col items-center justify-center gap-2
|
||||
transition-all duration-normal
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
${status === 'CLOCKED_IN'
|
||||
? 'bg-destructive text-white hover:scale-105 shadow-lg'
|
||||
: 'bg-tertiary text-tertiary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
CLOCK OUT
|
||||
<LogOut size={24} />
|
||||
Clock Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-neutral-200 overflow-hidden">
|
||||
<h3 className="text-lg font-bold text-neutral-800 p-6 border-b border-neutral-100">Recent Logs</h3>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-neutral-50 text-neutral-500">
|
||||
<tr>
|
||||
<th className="p-4">Date</th>
|
||||
<th className="p-4">Start</th>
|
||||
<th className="p-4">End</th>
|
||||
<th className="p-4">Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{logs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td className="p-4">{new Date(log.startTime).toLocaleDateString()}</td>
|
||||
<td className="p-4">{new Date(log.startTime).toLocaleTimeString()}</td>
|
||||
<td className="p-4">{log.endTime ? new Date(log.endTime).toLocaleTimeString() : '-'}</td>
|
||||
<td className="p-4">{log.activityType}</td>
|
||||
</tr>
|
||||
{/* Logs Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle flex items-center gap-2">
|
||||
<Clock size={16} className="text-tertiary" />
|
||||
<h3 className="text-sm font-medium text-primary">Recent Logs</h3>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="skeleton w-8 h-8 rounded-full mx-auto" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-tertiary text-sm">
|
||||
No time logs yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-subtle">
|
||||
{logs.slice(0, 10).map(log => (
|
||||
<div key={log.id} className="flex items-center justify-between p-4 hover:bg-tertiary transition-colors duration-fast">
|
||||
<div>
|
||||
<div className="text-sm text-primary font-medium">
|
||||
{new Date(log.startTime).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-xs text-tertiary">
|
||||
{log.activityType}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-primary">
|
||||
{new Date(log.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{' → '}
|
||||
{log.endTime
|
||||
? new Date(log.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: <span className="text-success">Active</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { batchesApi, Batch } from '../lib/batchesApi';
|
||||
import { touchPointsApi } from '../lib/touchPointsApi';
|
||||
import { Loader2, Droplets, Utensils, Scissors, Dumbbell, Search, ShieldCheck, Shovel, Sprout, Fingerprint } from 'lucide-react';
|
||||
import { PageHeader } from '../components/layout/PageHeader';
|
||||
import { Droplets, Utensils, Scissors, Dumbbell, Search, ShieldCheck, Shovel, Sprout, Fingerprint, ChevronLeft, Loader2 } from 'lucide-react';
|
||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
export default function TouchPointPage() {
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const [batches, setBatches] = useState<Batch[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedBatch, setSelectedBatch] = useState<Batch | null>(null);
|
||||
|
|
@ -14,7 +14,6 @@ export default function TouchPointPage() {
|
|||
const [notes, setNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Load active batches
|
||||
useEffect(() => {
|
||||
batchesApi.getAll().then(data => {
|
||||
setBatches(data.filter(b => b.status === 'ACTIVE'));
|
||||
|
|
@ -26,16 +25,26 @@ export default function TouchPointPage() {
|
|||
}, []);
|
||||
|
||||
const actions = [
|
||||
{ id: 'WATER', label: 'Water', icon: Droplets, color: 'text-blue-500 bg-blue-50 border-blue-200' },
|
||||
{ id: 'FEED', label: 'Feed', icon: Utensils, color: 'text-green-500 bg-green-50 border-green-200' },
|
||||
{ id: 'PRUNE', label: 'Prune', icon: Scissors, color: 'text-amber-500 bg-amber-50 border-amber-200' },
|
||||
{ id: 'TRAIN', label: 'Train', icon: Dumbbell, color: 'text-purple-500 bg-purple-50 border-purple-200' },
|
||||
{ id: 'INSPECT', label: 'Inspect', icon: Search, color: 'text-slate-500 bg-slate-50 border-slate-200' },
|
||||
{ id: 'IPM', label: 'IPM', icon: ShieldCheck, color: 'text-red-500 bg-red-50 border-red-200' },
|
||||
{ id: 'TRANSPLANT', label: 'Transplant', icon: Shovel, color: 'text-orange-500 bg-orange-50 border-orange-200' },
|
||||
{ id: 'HARVEST', label: 'Harvest', icon: Sprout, color: 'text-emerald-500 bg-emerald-50 border-emerald-200' },
|
||||
{ id: 'WATER', label: 'Water', icon: Droplets, accent: 'accent' as const },
|
||||
{ id: 'FEED', label: 'Feed', icon: Utensils, accent: 'success' as const },
|
||||
{ id: 'PRUNE', label: 'Prune', icon: Scissors, accent: 'warning' as const },
|
||||
{ id: 'TRAIN', label: 'Train', icon: Dumbbell, accent: 'accent' as const },
|
||||
{ id: 'INSPECT', label: 'Inspect', icon: Search, accent: 'default' as const },
|
||||
{ id: 'IPM', label: 'IPM', icon: ShieldCheck, accent: 'destructive' as const },
|
||||
{ id: 'TRANSPLANT', label: 'Transplant', icon: Shovel, accent: 'warning' as const },
|
||||
{ id: 'HARVEST', label: 'Harvest', icon: Sprout, accent: 'success' as const },
|
||||
];
|
||||
|
||||
const getAccentClasses = (accent: string) => {
|
||||
switch (accent) {
|
||||
case 'success': return 'bg-success-muted text-success border-success/20 hover:border-success/40';
|
||||
case 'warning': return 'bg-warning-muted text-warning border-warning/20 hover:border-warning/40';
|
||||
case 'destructive': return 'bg-destructive-muted text-destructive border-destructive/20 hover:border-destructive/40';
|
||||
case 'accent': return 'bg-accent-muted text-accent border-accent/20 hover:border-accent/40';
|
||||
default: return 'bg-tertiary text-secondary border-default hover:border-strong';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedBatch || !actionType) return;
|
||||
setSubmitting(true);
|
||||
|
|
@ -45,98 +54,156 @@ export default function TouchPointPage() {
|
|||
type: actionType as any,
|
||||
notes,
|
||||
});
|
||||
// Reset
|
||||
addToast('Touch point recorded!', 'success');
|
||||
setActionType(null);
|
||||
setNotes('');
|
||||
alert('Touch point recorded!');
|
||||
} catch (err) {
|
||||
alert('Failed to record');
|
||||
addToast('Failed to record', 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="flex justify-center p-8"><Loader2 className="animate-spin" /></div>;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader title="Quick Actions" subtitle="Record plant interactions" />
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader
|
||||
title="Quick Actions"
|
||||
description="Record plant interactions and tasks efficiently"
|
||||
icon={Fingerprint}
|
||||
iconColor="text-purple-600"
|
||||
subtitle="Record plant interactions efficiently"
|
||||
/>
|
||||
|
||||
{/* Step 1: Select Batch */}
|
||||
{!selectedBatch ? (
|
||||
<div className="grid gap-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Select Batch</h3>
|
||||
{batches.map(batch => (
|
||||
<button
|
||||
key={batch.id}
|
||||
onClick={() => setSelectedBatch(batch)}
|
||||
className="p-4 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 text-left hover:border-emerald-500 transition-colors group"
|
||||
>
|
||||
<div className="font-bold text-lg text-slate-900 dark:text-white group-hover:text-emerald-600 transition-colors">
|
||||
{batch.name?.replace('[DEMO] ', '')}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">{batch.strain}</div>
|
||||
</button>
|
||||
))}
|
||||
{batches.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
No active batches found.
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-medium text-tertiary uppercase tracking-wider px-1">
|
||||
Select Batch
|
||||
</h3>
|
||||
|
||||
{batches.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Fingerprint}
|
||||
title="No active batches"
|
||||
description="Create a batch to start recording touch points."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{batches.map(batch => (
|
||||
<button
|
||||
key={batch.id}
|
||||
onClick={() => setSelectedBatch(batch)}
|
||||
className="card card-interactive p-4 text-left group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-primary text-sm group-hover:text-accent transition-colors duration-fast">
|
||||
{batch.name?.replace('[DEMO] ', '')}
|
||||
</div>
|
||||
<div className="text-xs text-tertiary">{batch.strain}</div>
|
||||
</div>
|
||||
<ChevronLeft size={16} className="text-tertiary rotate-180 group-hover:translate-x-1 transition-transform duration-fast" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !actionType ? (
|
||||
<div className="space-y-6">
|
||||
<button onClick={() => setSelectedBatch(null)} className="text-sm text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors">
|
||||
← Back to Batches
|
||||
/* Step 2: Select Action */
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedBatch(null)}
|
||||
className="flex items-center gap-1 text-xs text-tertiary hover:text-primary transition-colors duration-fast"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
Back to Batches
|
||||
</button>
|
||||
<div className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-emerald-500/20 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">{selectedBatch.name?.replace('[DEMO] ', '')}</h2>
|
||||
<p className="text-sm text-slate-500">Select an action to record</p>
|
||||
|
||||
<div className="card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-primary text-sm">
|
||||
{selectedBatch.name?.replace('[DEMO] ', '')}
|
||||
</div>
|
||||
<div className="text-xs text-tertiary">Select an action</div>
|
||||
</div>
|
||||
<span className="badge">{selectedBatch.strain}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{actions.map(action => (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => setActionType(action.id)}
|
||||
className={`p-6 rounded-xl border-2 flex flex-col items-center gap-3 transition-all active:scale-95 hover:shadow-md ${action.color}`}
|
||||
className={`
|
||||
p-5 rounded-lg border flex flex-col items-center gap-3
|
||||
transition-all duration-fast active:scale-95
|
||||
${getAccentClasses(action.accent)}
|
||||
`}
|
||||
>
|
||||
<action.icon className="w-8 h-8" />
|
||||
<span className="font-bold">{action.label}</span>
|
||||
<action.icon size={24} />
|
||||
<span className="text-sm font-medium">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<button onClick={() => setActionType(null)} className="text-sm text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors">
|
||||
← Back to Actions
|
||||
/* Step 3: Confirm */
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setActionType(null)}
|
||||
className="flex items-center gap-1 text-xs text-tertiary hover:text-primary transition-colors duration-fast"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
Back to Actions
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-xl font-semibold text-primary">
|
||||
{(() => {
|
||||
const action = actions.find(a => a.id === actionType);
|
||||
const Icon = action?.icon || Fingerprint;
|
||||
return <Icon size={24} className="text-accent" />;
|
||||
})()}
|
||||
{actions.find(a => a.id === actionType)?.label}
|
||||
<span className="text-slate-400">for {selectedBatch.name?.replace('[DEMO] ', '')}</span>
|
||||
</h1>
|
||||
<span className="text-tertiary font-normal text-base">
|
||||
for {selectedBatch.name?.replace('[DEMO] ', '')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-slate-900 dark:text-white">Notes (Optional)</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
className="w-full p-4 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm h-32 focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||
placeholder="Add details..."
|
||||
/>
|
||||
<div className="card p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">
|
||||
Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
className="input w-full h-32 resize-none py-3"
|
||||
placeholder="Add details about this action..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-4 bg-emerald-600 text-white font-bold rounded-xl shadow-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
className="btn btn-primary w-full h-12"
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save Record'}
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Record'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Users, UserPlus, LogIn, LogOut, Clock, Building, Search,
|
||||
Filter, Download, Calendar, Shield, AlertTriangle, CheckCircle
|
||||
Users, UserPlus, LogIn, LogOut, Building, Search,
|
||||
Filter, Download, Calendar, Shield, AlertTriangle, X
|
||||
} from 'lucide-react';
|
||||
|
||||
import { visitorsApi, Visitor, VisitorLog, ActiveVisitor, AccessZone } from '../lib/visitorsApi';
|
||||
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
type TabType = 'active' | 'all' | 'zones' | 'reports';
|
||||
|
||||
export default function VisitorManagementPage() {
|
||||
const { addToast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('active');
|
||||
const [activeVisitors, setActiveVisitors] = useState<ActiveVisitor[]>([]);
|
||||
const [allVisitors, setAllVisitors] = useState<(Visitor & { logs: VisitorLog[] })[]>([]);
|
||||
const [zones, setZones] = useState<AccessZone[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [revokeModal, setRevokeModal] = useState<{ visitor: ActiveVisitor, notes: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
|
@ -43,9 +47,10 @@ export default function VisitorManagementPage() {
|
|||
const handleCheckOut = async (visitor: ActiveVisitor) => {
|
||||
try {
|
||||
await visitorsApi.checkOut(visitor.visitorId);
|
||||
addToast(`${visitor.name} checked out`, 'success');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to check out:', error);
|
||||
addToast('Failed to check out', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -56,102 +61,68 @@ export default function VisitorManagementPage() {
|
|||
return `${hours}h ${minutes % 60}m`;
|
||||
};
|
||||
|
||||
const [revokeModal, setRevokeModal] = useState<{ visitor: ActiveVisitor, notes: string } | null>(null);
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!revokeModal) return;
|
||||
try {
|
||||
await visitorsApi.revoke(revokeModal.visitor.visitorId, revokeModal.notes);
|
||||
setRevokeModal(null);
|
||||
addToast('Access revoked', 'warning');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke access:', error);
|
||||
addToast('Failed to revoke access', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
VISITOR: 'bg-blue-500/20 text-blue-400',
|
||||
CONTRACTOR: 'bg-amber-500/20 text-amber-400',
|
||||
INSPECTOR: 'bg-red-500/20 text-red-400',
|
||||
VENDOR: 'bg-purple-500/20 text-purple-400',
|
||||
DELIVERY: 'bg-emerald-500/20 text-emerald-400',
|
||||
OTHER: 'bg-slate-500/20 text-slate-400'
|
||||
const getTypeBadge = (type: string) => {
|
||||
const badges: Record<string, string> = {
|
||||
VISITOR: 'badge-accent',
|
||||
CONTRACTOR: 'badge-warning',
|
||||
INSPECTOR: 'badge-destructive',
|
||||
VENDOR: 'badge-accent',
|
||||
DELIVERY: 'badge-success',
|
||||
OTHER: 'badge'
|
||||
};
|
||||
return badges[type] || 'badge';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Shield className="text-emerald-400" />
|
||||
Visitor Management
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm">Real-time facility access monitoring</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="space-y-6 animate-in">
|
||||
<PageHeader
|
||||
title="Visitor Management"
|
||||
subtitle="Real-time facility access monitoring"
|
||||
actions={
|
||||
<a
|
||||
href="/kiosk"
|
||||
target="_blank"
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-medium transition-colors"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
Open Kiosk
|
||||
<UserPlus size={16} />
|
||||
<span className="hidden sm:inline">Open Kiosk</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-800/50 rounded-xl p-4 border border-white/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-emerald-500/20 rounded-lg">
|
||||
<Users size={20} className="text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 text-sm">On-Site Now</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{activeVisitors.length}</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/50 rounded-xl p-4 border border-white/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<LogIn size={20} className="text-blue-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 text-sm">Today's Visits</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">--</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/50 rounded-xl p-4 border border-white/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||
<Building size={20} className="text-purple-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 text-sm">Zones Active</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{zones.length}</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/50 rounded-xl p-4 border border-white/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-amber-500/20 rounded-lg">
|
||||
<AlertTriangle size={20} className="text-amber-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 text-sm">Alerts</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">0</p>
|
||||
</div>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard icon={Users} label="On-Site Now" value={activeVisitors.length} accent="success" />
|
||||
<MetricCard icon={LogIn} label="Today's Visits" value="--" accent="accent" />
|
||||
<MetricCard icon={Building} label="Zones Active" value={zones.length} accent="accent" />
|
||||
<MetricCard icon={AlertTriangle} label="Alerts" value="0" accent="warning" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-white/10 pb-2">
|
||||
<div className="card p-1 inline-flex gap-1">
|
||||
{(['active', 'all', 'zones', 'reports'] as TabType[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors capitalize
|
||||
${activeTab === tab
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
className={`
|
||||
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast capitalize
|
||||
${activeTab === tab
|
||||
? 'bg-accent text-white'
|
||||
: 'text-secondary hover:text-primary hover:bg-tertiary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab === 'active' ? 'Live View' : tab}
|
||||
</button>
|
||||
|
|
@ -160,61 +131,60 @@ export default function VisitorManagementPage() {
|
|||
|
||||
{/* Active Visitors Tab */}
|
||||
{activeTab === 'active' && (
|
||||
<div className="bg-slate-800/50 rounded-xl border border-white/10 overflow-hidden">
|
||||
{activeVisitors.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Users className="mx-auto mb-4 opacity-50" size={48} />
|
||||
<p>No visitors currently on-site</p>
|
||||
<div className="card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : activeVisitors.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No visitors on-site"
|
||||
description="Visitors will appear here when they check in."
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-white/10">
|
||||
<div className="divide-y divide-subtle">
|
||||
{activeVisitors.map(visitor => (
|
||||
<div key={visitor.logId} className="p-4 flex items-center justify-between hover:bg-slate-700/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-slate-700 rounded-full flex items-center justify-center text-white font-bold">
|
||||
<div key={visitor.logId} className="p-4 flex items-center justify-between hover:bg-tertiary transition-colors duration-fast">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-accent-muted rounded-full flex items-center justify-center text-accent font-semibold text-sm">
|
||||
{visitor.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">{visitor.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${typeColors[visitor.type]}`}>
|
||||
<span className="font-medium text-primary text-sm">{visitor.name}</span>
|
||||
<span className={`${getTypeBadge(visitor.type)} text-[10px]`}>
|
||||
{visitor.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{visitor.company && `${visitor.company} • `}
|
||||
<div className="text-xs text-tertiary">
|
||||
{visitor.company && `${visitor.company} · `}
|
||||
{visitor.purpose}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right hidden md:block">
|
||||
<div className="text-xs text-slate-400">Badge</div>
|
||||
<div className="text-sm text-emerald-400 font-mono">{visitor.badgeNumber}</div>
|
||||
<div className="text-[10px] text-tertiary uppercase tracking-wider">Badge</div>
|
||||
<div className="text-xs text-accent font-mono">{visitor.badgeNumber}</div>
|
||||
</div>
|
||||
<div className="text-right hidden md:block">
|
||||
<div className="text-xs text-slate-400">Duration</div>
|
||||
<div className="text-sm text-white">{formatDuration(visitor.entryTime)}</div>
|
||||
<div className="text-[10px] text-tertiary uppercase tracking-wider">Duration</div>
|
||||
<div className="text-xs text-primary">{formatDuration(visitor.entryTime)}</div>
|
||||
</div>
|
||||
{visitor.escort && (
|
||||
<div className="text-right hidden md:block">
|
||||
<div className="text-xs text-slate-400">Escort</div>
|
||||
<div className="text-sm text-white">{visitor.escort.name}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleCheckOut(visitor)}
|
||||
className="bg-slate-700 hover:bg-slate-600 text-white px-3 py-2 rounded-lg flex items-center gap-2 text-sm transition-colors"
|
||||
className="btn btn-secondary text-xs h-8"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<LogOut size={14} />
|
||||
Sign Out
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRevokeModal({ visitor, notes: '' })}
|
||||
className="bg-red-500/10 hover:bg-red-500/20 text-red-500 px-3 py-2 rounded-lg flex items-center gap-2 text-sm transition-colors border border-red-500/20"
|
||||
className="btn btn-ghost text-destructive hover:bg-destructive-muted text-xs h-8"
|
||||
>
|
||||
<AlertTriangle size={16} />
|
||||
<AlertTriangle size={14} />
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -228,29 +198,29 @@ export default function VisitorManagementPage() {
|
|||
|
||||
{/* All Visitors Tab */}
|
||||
{activeTab === 'all' && (
|
||||
<div>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && loadData()}
|
||||
placeholder="Search all records..."
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-white placeholder:text-slate-500 focus:border-emerald-500 outline-none"
|
||||
className="input w-full pl-9"
|
||||
/>
|
||||
</div>
|
||||
<button className="bg-slate-800 border border-slate-700 px-4 py-2 rounded-lg text-slate-400 hover:text-white flex items-center gap-2">
|
||||
<Filter size={18} />
|
||||
<button className="btn btn-secondary">
|
||||
<Filter size={16} />
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800/50 rounded-xl border border-white/10 overflow-hidden">
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr className="text-left text-slate-400 text-sm">
|
||||
<thead className="bg-secondary">
|
||||
<tr className="text-left text-xs text-tertiary uppercase tracking-wider">
|
||||
<th className="p-4">Visitor</th>
|
||||
<th className="p-4">Type</th>
|
||||
<th className="p-4">Company</th>
|
||||
|
|
@ -258,27 +228,27 @@ export default function VisitorManagementPage() {
|
|||
<th className="p-4">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
<tbody className="divide-y divide-subtle">
|
||||
{allVisitors.map(visitor => (
|
||||
<tr key={visitor.id} className="hover:bg-slate-700/30">
|
||||
<tr key={visitor.id} className="hover:bg-tertiary transition-colors duration-fast">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-slate-700 rounded-full flex items-center justify-center text-white text-sm font-bold">
|
||||
<div className="w-8 h-8 bg-tertiary rounded-full flex items-center justify-center text-secondary text-xs font-semibold">
|
||||
{visitor.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{visitor.name}</div>
|
||||
<div className="text-xs text-slate-400">{visitor.email}</div>
|
||||
<div className="text-sm font-medium text-primary">{visitor.name}</div>
|
||||
<div className="text-xs text-tertiary">{visitor.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`text-xs px-2 py-1 rounded ${typeColors[visitor.type]}`}>
|
||||
<span className={`${getTypeBadge(visitor.type)} text-[10px]`}>
|
||||
{visitor.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-slate-300">{visitor.company || '-'}</td>
|
||||
<td className="p-4 text-slate-400 text-sm">
|
||||
<td className="p-4 text-sm text-secondary">{visitor.company || '—'}</td>
|
||||
<td className="p-4 text-xs text-tertiary">
|
||||
{visitor.logs[0]
|
||||
? new Date(visitor.logs[0].entryTime).toLocaleDateString()
|
||||
: 'Never'
|
||||
|
|
@ -286,11 +256,11 @@ export default function VisitorManagementPage() {
|
|||
</td>
|
||||
<td className="p-4">
|
||||
{visitor.logs[0]?.status === 'CHECKED_IN' ? (
|
||||
<span className="text-xs px-2 py-1 rounded bg-emerald-500/20 text-emerald-400">On-Site</span>
|
||||
<span className="badge-success text-[10px]">On-Site</span>
|
||||
) : visitor.logs[0]?.status === 'REVOKED' ? (
|
||||
<span className="text-xs px-2 py-1 rounded bg-red-500/20 text-red-400">REVOKED</span>
|
||||
<span className="badge-destructive text-[10px]">REVOKED</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 rounded bg-slate-500/20 text-slate-400">Off-Site</span>
|
||||
<span className="badge text-[10px]">Off-Site</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -303,88 +273,80 @@ export default function VisitorManagementPage() {
|
|||
|
||||
{/* Zones Tab */}
|
||||
{activeTab === 'zones' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{zones.map(zone => (
|
||||
<div key={zone.id} className="bg-slate-800/50 rounded-xl border border-white/10 p-4">
|
||||
<div key={zone.id} className="card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{zone.name}</h3>
|
||||
<span className="text-xs text-slate-400 font-mono">{zone.code}</span>
|
||||
<h3 className="font-medium text-primary text-sm">{zone.name}</h3>
|
||||
<span className="text-xs text-tertiary font-mono">{zone.code}</span>
|
||||
</div>
|
||||
<Building className="text-slate-400" size={24} />
|
||||
<Building className="text-tertiary" size={20} />
|
||||
</div>
|
||||
{zone.description && (
|
||||
<p className="text-sm text-slate-400 mb-3">{zone.description}</p>
|
||||
<p className="text-xs text-tertiary mb-3">{zone.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{zone.escortRequired && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400">Escort Required</span>
|
||||
)}
|
||||
{zone.ndaRequired && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-red-500/20 text-red-400">NDA Required</span>
|
||||
)}
|
||||
{zone.maxOccupancy && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-blue-500/20 text-blue-400">
|
||||
Max: {zone.maxOccupancy}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{zone.escortRequired && <span className="badge-warning text-[10px]">Escort Required</span>}
|
||||
{zone.ndaRequired && <span className="badge-destructive text-[10px]">NDA Required</span>}
|
||||
{zone.maxOccupancy && <span className="badge-accent text-[10px]">Max: {zone.maxOccupancy}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button className="bg-slate-800/30 hover:bg-slate-800/50 border-2 border-dashed border-slate-700 rounded-xl p-8 flex flex-col items-center justify-center gap-2 text-slate-400 hover:text-white transition-colors">
|
||||
<Building size={32} />
|
||||
<span>Add Zone</span>
|
||||
<button className="card card-interactive p-8 flex flex-col items-center justify-center gap-2 border-dashed">
|
||||
<Building size={24} className="text-tertiary" />
|
||||
<span className="text-sm text-tertiary">Add Zone</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reports Tab */}
|
||||
{activeTab === 'reports' && (
|
||||
<div className="bg-slate-800/50 rounded-xl border border-white/10 p-6">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-white">Visitor Reports</h3>
|
||||
<button className="bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 px-4 py-2 rounded-lg flex items-center gap-2 text-sm transition-colors">
|
||||
<Download size={18} />
|
||||
<h3 className="text-lg font-medium text-primary">Visitor Reports</h3>
|
||||
<button className="btn btn-secondary">
|
||||
<Download size={16} />
|
||||
Export Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-slate-700/30 rounded-lg p-4">
|
||||
<h4 className="text-white font-medium mb-2">Quick Reports</h4>
|
||||
<div className="bg-tertiary rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-primary mb-3">Quick Reports</h4>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<a href="#" className="text-blue-400 hover:text-blue-300 text-sm flex items-center gap-2">
|
||||
<Calendar size={16} />
|
||||
<a href="#" className="text-accent hover:underline text-sm flex items-center gap-2">
|
||||
<Calendar size={14} />
|
||||
Last 7 Days Visitor Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-blue-400 hover:text-blue-300 text-sm flex items-center gap-2">
|
||||
<Calendar size={16} />
|
||||
<a href="#" className="text-accent hover:underline text-sm flex items-center gap-2">
|
||||
<Calendar size={14} />
|
||||
Last 30 Days Visitor Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-blue-400 hover:text-blue-300 text-sm flex items-center gap-2">
|
||||
<Shield size={16} />
|
||||
<a href="#" className="text-accent hover:underline text-sm flex items-center gap-2">
|
||||
<Shield size={14} />
|
||||
Compliance Report (90 days)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-slate-700/30 rounded-lg p-4">
|
||||
<h4 className="text-white font-medium mb-2">Custom Report</h4>
|
||||
<div className="bg-tertiary rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-primary mb-3">Custom Report</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400">Date Range</label>
|
||||
<label className="text-xs text-tertiary">Date Range</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<input type="date" className="flex-1 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-white text-sm" />
|
||||
<input type="date" className="flex-1 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-white text-sm" />
|
||||
<input type="date" className="input flex-1 h-9 text-xs" />
|
||||
<input type="date" className="input flex-1 h-9 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full bg-emerald-500/20 text-emerald-400 py-2 rounded text-sm hover:bg-emerald-500/30">
|
||||
<button className="btn btn-ghost w-full text-accent hover:bg-accent-muted text-sm">
|
||||
Generate Report
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -395,24 +357,34 @@ export default function VisitorManagementPage() {
|
|||
|
||||
{/* Revoke Modal */}
|
||||
{revokeModal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-slate-900 border border-red-500/30 rounded-2xl w-full max-w-md p-6">
|
||||
<div className="flex items-center gap-3 text-red-500 mb-4">
|
||||
<AlertTriangle size={28} />
|
||||
<h3 className="text-xl font-bold">Revoke Access</h3>
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 animate-fade-in">
|
||||
<div className="card w-full max-w-md p-6 animate-scale-in">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
<h3 className="text-lg font-semibold">Revoke Access</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevokeModal(null)}
|
||||
className="p-2 hover:bg-tertiary rounded-md transition-colors"
|
||||
>
|
||||
<X size={16} className="text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-white mb-4">
|
||||
Are you sure you want to immediately revoke access for <span className="font-bold">{revokeModal.visitor.name}</span>?
|
||||
This will invalidate their digital badge and flag them in the system.
|
||||
<p className="text-sm text-secondary mb-4">
|
||||
Revoke access for <span className="font-semibold text-primary">{revokeModal.visitor.name}</span>?
|
||||
This will invalidate their badge.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm text-slate-400 mb-2">Reason for Revocation *</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">
|
||||
Reason for Revocation
|
||||
</label>
|
||||
<textarea
|
||||
value={revokeModal.notes}
|
||||
onChange={e => setRevokeModal({ ...revokeModal, notes: e.target.value })}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg p-3 text-white focus:border-red-500 outline-none h-24"
|
||||
className="input w-full h-24 py-3 resize-none"
|
||||
placeholder="Security violation, elapsed time, etc."
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -420,14 +392,14 @@ export default function VisitorManagementPage() {
|
|||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setRevokeModal(null)}
|
||||
className="flex-1 bg-slate-800 hover:bg-slate-700 text-white py-3 rounded-xl font-medium transition-colors"
|
||||
className="btn btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRevoke}
|
||||
disabled={!revokeModal.notes.trim()}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 disabled:opacity-50 text-white py-3 rounded-xl font-bold transition-colors"
|
||||
className="btn flex-1 bg-destructive hover:bg-destructive/90 text-white disabled:opacity-50"
|
||||
>
|
||||
REVOKE ACCESS
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Save, CheckSquare, Settings } from 'lucide-react';
|
||||
import { Save, CheckSquare, Settings, Camera, Loader2 } from 'lucide-react';
|
||||
import { settingsApi, WalkthroughSettings, PhotoRequirement } from '../lib/settingsApi';
|
||||
import { PageHeader } from '../components/ui/LinearPrimitives';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
const PHOTO_OPTIONS: { label: string; value: PhotoRequirement }[] = [
|
||||
{ label: 'Always Required', value: 'REQUIRED' },
|
||||
|
|
@ -10,19 +12,25 @@ const PHOTO_OPTIONS: { label: string; value: PhotoRequirement }[] = [
|
|||
];
|
||||
|
||||
export default function WalkthroughSettingsPage() {
|
||||
const { addToast } = useToast();
|
||||
const [settings, setSettings] = useState<WalkthroughSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await settingsApi.getWalkthrough();
|
||||
setSettings(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
addToast('Failed to load settings', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -30,82 +38,102 @@ export default function WalkthroughSettingsPage() {
|
|||
e.preventDefault();
|
||||
if (!settings) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await settingsApi.updateWalkthrough(settings);
|
||||
alert('Settings saved!');
|
||||
addToast('Settings saved!', 'success');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Failed to save settings.');
|
||||
addToast('Failed to save settings', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings) return <div>Loading...</div>;
|
||||
if (isLoading || !settings) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<PageHeader title="Walkthrough Settings" subtitle="Loading..." />
|
||||
<div className="card p-8 flex justify-center">
|
||||
<Loader2 className="animate-spin text-tertiary" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle switch component
|
||||
const Toggle = ({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) => (
|
||||
<label className="flex items-center justify-between p-3 rounded-md hover:bg-tertiary transition-colors duration-fast cursor-pointer">
|
||||
<span className="text-sm text-primary">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={onChange}
|
||||
className={`
|
||||
relative w-10 h-6 rounded-full transition-colors duration-fast
|
||||
${checked ? 'bg-accent' : 'bg-tertiary'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
absolute top-1 left-1 w-4 h-4 bg-white rounded-full
|
||||
transition-transform duration-fast
|
||||
${checked ? 'translate-x-4' : ''}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-20">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Settings className="text-emerald-600" />
|
||||
Walkthrough Settings
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm">
|
||||
Configure daily checklist requirements and photo rules.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleUpdate} className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="max-w-2xl mx-auto space-y-6 pb-20 animate-in">
|
||||
<PageHeader
|
||||
title="Walkthrough Settings"
|
||||
subtitle="Configure daily checklist requirements"
|
||||
/>
|
||||
|
||||
<form onSubmit={handleUpdate} className="space-y-6">
|
||||
{/* Enabled Sections */}
|
||||
<div className="bg-white dark:bg-slate-800 p-6 rounded-xl border border-slate-200 dark:border-slate-700 space-y-4">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<CheckSquare size={18} /> Enabled Modules
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableReservoirs}
|
||||
onChange={e => setSettings({ ...settings, enableReservoirs: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">Reservoir Checks</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableIrrigation}
|
||||
onChange={e => setSettings({ ...settings, enableIrrigation: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">Irrigation Checks</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enablePlantHealth}
|
||||
onChange={e => setSettings({ ...settings, enablePlantHealth: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">Plant Health Checks</span>
|
||||
</label>
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-subtle flex items-center gap-2">
|
||||
<CheckSquare size={16} className="text-accent" />
|
||||
<h3 className="text-sm font-medium text-primary">Enabled Modules</h3>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<Toggle
|
||||
checked={settings.enableReservoirs}
|
||||
onChange={() => setSettings({ ...settings, enableReservoirs: !settings.enableReservoirs })}
|
||||
label="Reservoir Checks"
|
||||
/>
|
||||
<Toggle
|
||||
checked={settings.enableIrrigation}
|
||||
onChange={() => setSettings({ ...settings, enableIrrigation: !settings.enableIrrigation })}
|
||||
label="Irrigation Checks"
|
||||
/>
|
||||
<Toggle
|
||||
checked={settings.enablePlantHealth}
|
||||
onChange={() => setSettings({ ...settings, enablePlantHealth: !settings.enablePlantHealth })}
|
||||
label="Plant Health Checks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Requirements */}
|
||||
<div className="bg-white dark:bg-slate-800 p-6 rounded-xl border border-slate-200 dark:border-slate-700 space-y-4">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Save size={18} /> Photo Rules
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-subtle flex items-center gap-2">
|
||||
<Camera size={16} className="text-accent" />
|
||||
<h3 className="text-sm font-medium text-primary">Photo Requirements</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-slate-300 mb-1">Reservoirs</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">
|
||||
Reservoirs
|
||||
</label>
|
||||
<select
|
||||
value={settings.reservoirPhotos}
|
||||
onChange={e => setSettings({ ...settings, reservoirPhotos: e.target.value as PhotoRequirement })}
|
||||
className="w-full p-2 bg-slate-50 dark:bg-slate-700 rounded-lg border-none"
|
||||
className="input w-full"
|
||||
>
|
||||
{PHOTO_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
|
|
@ -113,11 +141,13 @@ export default function WalkthroughSettingsPage() {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-slate-300 mb-1">Irrigation</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">
|
||||
Irrigation
|
||||
</label>
|
||||
<select
|
||||
value={settings.irrigationPhotos}
|
||||
onChange={e => setSettings({ ...settings, irrigationPhotos: e.target.value as PhotoRequirement })}
|
||||
className="w-full p-2 bg-slate-50 dark:bg-slate-700 rounded-lg border-none"
|
||||
className="input w-full"
|
||||
>
|
||||
{PHOTO_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
|
|
@ -125,11 +155,13 @@ export default function WalkthroughSettingsPage() {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 dark:text-slate-300 mb-1">Plant Health</label>
|
||||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">
|
||||
Plant Health
|
||||
</label>
|
||||
<select
|
||||
value={settings.plantHealthPhotos}
|
||||
onChange={e => setSettings({ ...settings, plantHealthPhotos: e.target.value as PhotoRequirement })}
|
||||
className="w-full p-2 bg-slate-50 dark:bg-slate-700 rounded-lg border-none"
|
||||
className="input w-full"
|
||||
>
|
||||
{PHOTO_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
|
|
@ -139,15 +171,23 @@ export default function WalkthroughSettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full md:w-auto px-8 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="btn btn-primary w-full h-12"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} />
|
||||
Save Configuration
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ export default {
|
|||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
// Mobile-first breakpoints optimized for cultivation floor tablets
|
||||
// Mobile-first breakpoints
|
||||
screens: {
|
||||
'xs': '375px', // Large phones
|
||||
'sm': '640px', // Small tablets portrait
|
||||
'md': '768px', // Tablets portrait (iPad)
|
||||
'lg': '1024px', // Tablets landscape / small desktop
|
||||
'xl': '1280px', // Desktop
|
||||
'2xl': '1536px', // Large desktop
|
||||
'xs': '375px',
|
||||
'sm': '640px',
|
||||
'md': '768px',
|
||||
'lg': '1024px',
|
||||
'xl': '1280px',
|
||||
'2xl': '1536px',
|
||||
},
|
||||
container: {
|
||||
center: true,
|
||||
|
|
@ -28,32 +28,64 @@ export default {
|
|||
},
|
||||
},
|
||||
extend: {
|
||||
// Linear-inspired color palette (Charcoal & Bone)
|
||||
colors: {
|
||||
// Neutral scale (primary palette)
|
||||
neutral: {
|
||||
50: '#FAFAFA',
|
||||
100: '#F5F5F5',
|
||||
200: '#E5E5E5',
|
||||
300: '#D4D4D4',
|
||||
400: '#A3A3A3',
|
||||
500: '#737373',
|
||||
600: '#525252',
|
||||
700: '#404040',
|
||||
800: '#262626',
|
||||
850: '#1F1F1F',
|
||||
900: '#171717',
|
||||
950: '#0A0A0A',
|
||||
},
|
||||
// Accent (Linear's desaturated blue-purple)
|
||||
accent: {
|
||||
DEFAULT: '#5E6AD2',
|
||||
hover: '#6E7AE2',
|
||||
muted: 'rgba(94, 106, 210, 0.15)',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
// Semantic colors
|
||||
success: {
|
||||
DEFAULT: '#22C55E',
|
||||
muted: 'rgba(34, 197, 94, 0.15)',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#EAB308',
|
||||
muted: 'rgba(234, 179, 8, 0.15)',
|
||||
foreground: '#171717',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: '#EF4444',
|
||||
muted: 'rgba(239, 68, 68, 0.15)',
|
||||
foreground: '#FFFFFF',
|
||||
},
|
||||
// Legacy support (mapped to neutral)
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "#10b981", // Emerald 500 (cannabis green)
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
DEFAULT: "#5E6AD2", // Now accent color
|
||||
foreground: "#FFFFFF",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "#3b82f6", // Blue 500
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
|
|
@ -64,25 +96,104 @@ export default {
|
|||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
'xs': '4px',
|
||||
'sm': '6px',
|
||||
'md': '8px',
|
||||
'lg': '12px',
|
||||
'xl': '16px',
|
||||
},
|
||||
// Touch-friendly spacing
|
||||
spacing: {
|
||||
'touch': '44px', // Minimum touch target size
|
||||
'touch-lg': '56px', // Large touch target
|
||||
'touch': '44px',
|
||||
'touch-lg': '56px',
|
||||
},
|
||||
// Linear-inspired typography
|
||||
fontFamily: {
|
||||
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||
display: ['Space Grotesk', 'Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'Consolas', 'monospace'],
|
||||
},
|
||||
// Mobile-optimized font sizes
|
||||
fontSize: {
|
||||
'xs': ['0.75rem', { lineHeight: '1rem' }],
|
||||
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
'base': ['1rem', { lineHeight: '1.5rem' }], // 16px minimum
|
||||
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||
'xs': ['11px', { lineHeight: '1.4', letterSpacing: '0.01em' }],
|
||||
'sm': ['13px', { lineHeight: '1.5', letterSpacing: '-0.01em' }],
|
||||
'base': ['14px', { lineHeight: '1.6', letterSpacing: '-0.01em' }],
|
||||
'lg': ['16px', { lineHeight: '1.5', letterSpacing: '-0.02em', fontWeight: '500' }],
|
||||
'xl': ['18px', { lineHeight: '1.4', letterSpacing: '-0.02em', fontWeight: '600' }],
|
||||
'2xl': ['24px', { lineHeight: '1.3', letterSpacing: '-0.02em', fontWeight: '600' }],
|
||||
'3xl': ['30px', { lineHeight: '1.2', letterSpacing: '-0.02em', fontWeight: '700' }],
|
||||
'4xl': ['36px', { lineHeight: '1.1', letterSpacing: '-0.02em', fontWeight: '700' }],
|
||||
},
|
||||
// Animation timing (Linear-style)
|
||||
transitionTimingFunction: {
|
||||
'out-expo': 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'in-out-expo': 'cubic-bezier(0.87, 0, 0.13, 1)',
|
||||
'spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
},
|
||||
transitionDuration: {
|
||||
'instant': '50ms',
|
||||
'fast': '100ms',
|
||||
'normal': '150ms',
|
||||
'slow': '250ms',
|
||||
'slower': '400ms',
|
||||
},
|
||||
// Keyframe animations
|
||||
keyframes: {
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
'fade-out': {
|
||||
'0%': { opacity: '1' },
|
||||
'100%': { opacity: '0' },
|
||||
},
|
||||
'slide-up': {
|
||||
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'slide-down': {
|
||||
'0%': { opacity: '0', transform: 'translateY(-8px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'scale-in': {
|
||||
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
'scale-out': {
|
||||
'0%': { opacity: '1', transform: 'scale(1)' },
|
||||
'100%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
},
|
||||
'shimmer': {
|
||||
'0%': { backgroundPosition: '-200% 0' },
|
||||
'100%': { backgroundPosition: '200% 0' },
|
||||
},
|
||||
'pulse-subtle': {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
'spin-slow': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fade-in 150ms ease-out',
|
||||
'fade-out': 'fade-out 150ms ease-out',
|
||||
'slide-up': 'slide-up 200ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'slide-down': 'slide-down 200ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'scale-in': 'scale-in 150ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'scale-out': 'scale-out 150ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'shimmer': 'shimmer 2s linear infinite',
|
||||
'pulse-subtle': 'pulse-subtle 2s ease-in-out infinite',
|
||||
'spin-slow': 'spin-slow 3s linear infinite',
|
||||
},
|
||||
// Box shadows (subtle, Linear-style)
|
||||
boxShadow: {
|
||||
'xs': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
'sm': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
'focus': '0 0 0 3px rgba(94, 106, 210, 0.25)',
|
||||
'focus-destructive': '0 0 0 3px rgba(239, 68, 68, 0.25)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue