Initial commit: 4NK Waste & Water Simulator
**Motivations :** * Create a complete simulator for 4NK Waste & Water modular waste treatment infrastructure * Implement frontend-only application with client-side data persistence * Provide seed data for wastes and natural regulators from specifications **Root causes :** * Need for a simulation tool to configure and manage waste treatment projects * Requirement for localhost-only access with persistent client-side storage * Need for initial seed data to bootstrap the application **Correctifs :** * Implemented authentication system with AuthContext * Fixed login/logout functionality with proper state management * Created placeholder pages for all routes **Evolutions :** * Complete application structure with React, TypeScript, and Vite * Seed data for 9 waste types and 52 natural regulators * Settings page with import/export and seed data loading functionality * Configuration pages for wastes and regulators with CRUD operations * Project management pages structure * Business plan and yields pages placeholders * Comprehensive UI/UX design system (dark mode only) * Navigation system with sidebar and header **Page affectées :** * All pages: Login, Dashboard, Waste Configuration, Regulators Configuration, Services Configuration * Project pages: Project List, Project Configuration, Treatment Sites, Waste Sites, Investors, Administrative Procedures * Analysis pages: Yields, Business Plan * Utility pages: Settings, Help * Components: Layout, Sidebar, Header, base components (Button, Input, Select, Card, Badge, Table) * Utils: Storage, seed data, formatters, validators, constants * Types: Complete TypeScript definitions for all entities
This commit is contained in:
commit
c7db6590f0
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
96
README.md
Normal file
96
README.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# 4NK Waste & Water - Simulator
|
||||||
|
|
||||||
|
Modular waste treatment infrastructure simulator for 4NK Waste & Water.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **React** (latest version)
|
||||||
|
- **TypeScript**
|
||||||
|
- **React Router** (for routing)
|
||||||
|
- **Vite** (development server only, no build tool for production)
|
||||||
|
- **No state management library** (use React useState, useContext)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (latest LTS version)
|
||||||
|
- npm, yarn, or pnpm
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# or
|
||||||
|
yarn install
|
||||||
|
# or
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:3000`
|
||||||
|
|
||||||
|
**Important**: The application can only be accessed from localhost (127.0.0.1). Access from other hosts will be blocked.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/src
|
||||||
|
/components
|
||||||
|
/base # Base reusable components
|
||||||
|
/composite # Composite components
|
||||||
|
/layout # Layout components (Header, Sidebar)
|
||||||
|
/shared # Shared business logic components
|
||||||
|
/pages # Page components
|
||||||
|
/hooks # Custom React hooks
|
||||||
|
/utils
|
||||||
|
/calculations # Calculation functions
|
||||||
|
/formatters # Data formatting
|
||||||
|
/validators # Validation functions
|
||||||
|
/constants # Constants and default values
|
||||||
|
/types # TypeScript type definitions
|
||||||
|
/data # Seed data (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Waste configuration
|
||||||
|
- Natural regulators configuration
|
||||||
|
- Services configuration
|
||||||
|
- Project management
|
||||||
|
- Treatment sites management
|
||||||
|
- Waste sites management
|
||||||
|
- Investors management
|
||||||
|
- Administrative procedures
|
||||||
|
- Yields calculation and display
|
||||||
|
- Business plan with 10-year projections
|
||||||
|
- Data export/import (JSON)
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
All data is stored locally in the browser using localStorage. No backend is required.
|
||||||
|
|
||||||
|
- Storage key: `4nkwaste_simulator_data`
|
||||||
|
- User session: `4nkwaste_simulator_user`
|
||||||
|
- Data format: JSON
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **Specification**: `specification.md`
|
||||||
|
- **Data Schemas**: `data_schemas.md`
|
||||||
|
- **Formulas Reference**: `formulas_reference.md`
|
||||||
|
- **User Workflow**: `user_workflow.md`
|
||||||
|
- **Constants**: `constants.ts`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private project - 4NK Waste & Water
|
||||||
2439
Régulateurs (1).md
Normal file
2439
Régulateurs (1).md
Normal file
File diff suppressed because it is too large
Load Diff
406
data_schemas.md
Normal file
406
data_schemas.md
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
# Data Schemas and Relations
|
||||||
|
|
||||||
|
## 1. JSON Data Structure
|
||||||
|
|
||||||
|
### 1.1 Root Storage Structure
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lastModified": "2024-01-01T00:00:00Z",
|
||||||
|
"users": [],
|
||||||
|
"wastes": [],
|
||||||
|
"regulators": [],
|
||||||
|
"services": [],
|
||||||
|
"treatmentSites": [],
|
||||||
|
"wasteSites": [],
|
||||||
|
"investors": [],
|
||||||
|
"administrativeProcedures": [],
|
||||||
|
"projects": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Entity Schemas
|
||||||
|
|
||||||
|
### 2.1 User Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"username": "string",
|
||||||
|
"password": "string (hashed or plain for localhost)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"lastLogin": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Waste Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"name": "string",
|
||||||
|
"originType": "string (animals|markets|restaurants|other)",
|
||||||
|
"originSubType": "string (specific type)",
|
||||||
|
"originUnitsPer1000m3Methane": "number",
|
||||||
|
"bmp": "number (Nm³ CH₄/kg VS)",
|
||||||
|
"waterPercentage": "number (0-100)",
|
||||||
|
"regulationNeeds": "string[]",
|
||||||
|
"regulatoryCharacteristics": {
|
||||||
|
"nitrogen": "number (optional)",
|
||||||
|
"phosphorus": "number (optional)",
|
||||||
|
"potassium": "number (optional)",
|
||||||
|
"carbonNitrogenRatio": "number (optional)"
|
||||||
|
},
|
||||||
|
"maxStorageDuration": "number (days)",
|
||||||
|
"notes": "string (optional)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"updatedAt": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Natural Regulator Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"name": "string",
|
||||||
|
"type": "string",
|
||||||
|
"regulatoryCharacteristics": {
|
||||||
|
"nitrogen": "number (optional)",
|
||||||
|
"phosphorus": "number (optional)",
|
||||||
|
"potassium": "number (optional)",
|
||||||
|
"carbonNitrogenRatio": "number (optional)",
|
||||||
|
"phAdjustment": "number (optional, -14 to 14)",
|
||||||
|
"metalBinding": "boolean (optional)",
|
||||||
|
"pathogenReduction": "boolean (optional)"
|
||||||
|
},
|
||||||
|
"applicationConditions": "string",
|
||||||
|
"dosageRequirements": {
|
||||||
|
"min": "number",
|
||||||
|
"max": "number",
|
||||||
|
"unit": "string (kg/t|L/t|%)"
|
||||||
|
},
|
||||||
|
"notes": "string (optional)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"updatedAt": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Service Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"name": "string",
|
||||||
|
"type": "string (rawRental|biologicalTreatment|bitcoinManagement|fertilizers|wasteHeat|carbonCredits|brownfield|transport)",
|
||||||
|
"pricing": {
|
||||||
|
"year1": "number (€/module/year)",
|
||||||
|
"year2": "number (€/module/year)",
|
||||||
|
"year3": "number (€/module/year)",
|
||||||
|
"year4": "number (€/module/year)",
|
||||||
|
"year5": "number (€/module/year)",
|
||||||
|
"year6": "number (€/module/year)",
|
||||||
|
"year7": "number (€/module/year)",
|
||||||
|
"year8": "number (€/module/year)",
|
||||||
|
"year9": "number (€/module/year)",
|
||||||
|
"year10": "number (€/module/year)"
|
||||||
|
},
|
||||||
|
"notes": "string (optional)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"updatedAt": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Treatment Site Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"name": "string",
|
||||||
|
"status": "string (toBeApproached|loiOk|inProgress|completed)",
|
||||||
|
"location": {
|
||||||
|
"address": "string (optional)",
|
||||||
|
"coordinates": {
|
||||||
|
"lat": "number (optional)",
|
||||||
|
"lng": "number (optional)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"altitude": "number (meters)",
|
||||||
|
"availableGroundSurface": "number (m²)",
|
||||||
|
"monthlyTemperatures": [
|
||||||
|
"number (January, °C)",
|
||||||
|
"number (February, °C)",
|
||||||
|
"number (March, °C)",
|
||||||
|
"number (April, °C)",
|
||||||
|
"number (May, °C)",
|
||||||
|
"number (June, °C)",
|
||||||
|
"number (July, °C)",
|
||||||
|
"number (August, °C)",
|
||||||
|
"number (September, °C)",
|
||||||
|
"number (October, °C)",
|
||||||
|
"number (November, °C)",
|
||||||
|
"number (December, °C)"
|
||||||
|
],
|
||||||
|
"subscribedServices": "string[] (array of service IDs)",
|
||||||
|
"notes": "string (optional)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"updatedAt": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 Waste Site Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"name": "string",
|
||||||
|
"type": "string",
|
||||||
|
"status": "string (toBeApproached|loiOk|inProgress|completed)",
|
||||||
|
"wasteType": "string (reference to waste ID)",
|
||||||
|
"quantityRange": {
|
||||||
|
"min": "number (t/day)",
|
||||||
|
"max": "number (t/day)"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"name": "string",
|
||||||
|
"email": "string (optional)",
|
||||||
|
"phone": "string (optional)",
|
||||||
|
"address": "string (optional)"
|
||||||
|
},
|
||||||
|
"collectionType": "string",
|
||||||
|
"distance": "number (km, from treatment site)",
|
||||||
|
"notes": "string (optional)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"updatedAt": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 Investor Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"name": "string",
|
||||||
|
"type": "string",
|
||||||
|
"amountRange": {
|
||||||
|
"min": "number (€)",
|
||||||
|
"max": "number (€)"
|
||||||
|
},
|
||||||
|
"geographicRegions": "string[]",
|
||||||
|
"wasteRange": {
|
||||||
|
"min": "number (t/day)",
|
||||||
|
"max": "number (t/day)"
|
||||||
|
},
|
||||||
|
"wasteTypes": "string[] (array of waste type IDs)",
|
||||||
|
"solarPanelsRange": {
|
||||||
|
"min": "number (kW)",
|
||||||
|
"max": "number (kW)"
|
||||||
|
},
|
||||||
|
"notes": "string (optional)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"updatedAt": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.8 Administrative Procedure Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"name": "string",
|
||||||
|
"type": "string (ICPE|spreading|other)",
|
||||||
|
"delays": "number (days)",
|
||||||
|
"contact": {
|
||||||
|
"name": "string",
|
||||||
|
"email": "string (optional)",
|
||||||
|
"phone": "string (optional)",
|
||||||
|
"organization": "string (optional)"
|
||||||
|
},
|
||||||
|
"regions": "string[]",
|
||||||
|
"notes": "string (optional)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"updatedAt": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.9 Project Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (UUID)",
|
||||||
|
"name": "string",
|
||||||
|
"startDate": "ISO 8601 date string",
|
||||||
|
"endDate": "ISO 8601 date string",
|
||||||
|
"treatmentSiteId": "string (reference to treatment site)",
|
||||||
|
"collectionSiteIds": "string[] (array of waste site IDs)",
|
||||||
|
"numberOfModules": "number",
|
||||||
|
"transportBySite": "boolean",
|
||||||
|
"wasteCharacteristicsOverride": {
|
||||||
|
"wasteId": "string (optional, reference to waste)",
|
||||||
|
"bmp": "number (optional, override)",
|
||||||
|
"waterPercentage": "number (optional, override)",
|
||||||
|
"regulatoryNeeds": "string[] (optional, override)"
|
||||||
|
},
|
||||||
|
"administrativeProcedures": [
|
||||||
|
{
|
||||||
|
"procedureId": "string (reference to administrative procedure)",
|
||||||
|
"status": "string (toDo|done|na)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"investments": [
|
||||||
|
{
|
||||||
|
"investorId": "string (reference to investor)",
|
||||||
|
"status": "string (toBeApproached|loiOk|inProgress|completed)",
|
||||||
|
"amount": "number (€)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"businessPlan": {
|
||||||
|
"revenues": {
|
||||||
|
"rawRental": "number[] (10 years, €/year)",
|
||||||
|
"biologicalTreatment": "number[] (10 years, €/year)",
|
||||||
|
"bitcoinManagement": "number[] (10 years, €/year)",
|
||||||
|
"fertilizers": "number[] (10 years, €/year)",
|
||||||
|
"wasteHeat": "number[] (10 years, €/year)",
|
||||||
|
"carbonCredits": "number[] (10 years, €/year)",
|
||||||
|
"brownfield": "number[] (10 years, €/year)",
|
||||||
|
"transport": "number[] (10 years, €/year)",
|
||||||
|
"commercialPartnerships": "number[] (10 years, €/year)",
|
||||||
|
"other": "number[] (10 years, €/year)"
|
||||||
|
},
|
||||||
|
"variableCosts": {
|
||||||
|
"rentalServices": "number[] (10 years, €/year)",
|
||||||
|
"commissions": "number[] (10 years, €/year)",
|
||||||
|
"otherVariable": "number[] (10 years, €/year)",
|
||||||
|
"transport": "number[] (10 years, €/year)"
|
||||||
|
},
|
||||||
|
"fixedCosts": {
|
||||||
|
"salaries": "number[] (10 years, €/year)",
|
||||||
|
"marketing": "number[] (10 years, €/year)",
|
||||||
|
"rd": "number[] (10 years, €/year)",
|
||||||
|
"administrative": "number[] (10 years, €/year)",
|
||||||
|
"otherGeneral": "number[] (10 years, €/year)"
|
||||||
|
},
|
||||||
|
"investments": {
|
||||||
|
"equipment": "number[] (10 years, €/year)",
|
||||||
|
"technology": "number[] (10 years, €/year)",
|
||||||
|
"patents": "number[] (10 years, €/year)"
|
||||||
|
},
|
||||||
|
"useOfFunds": {
|
||||||
|
"productDevelopment": "number[] (10 years, €/year)",
|
||||||
|
"marketing": "number[] (10 years, €/year)",
|
||||||
|
"team": "number[] (10 years, €/year)",
|
||||||
|
"structure": "number[] (10 years, €/year)"
|
||||||
|
},
|
||||||
|
"kpis": {
|
||||||
|
"activeUsers": "number[] (10 years)",
|
||||||
|
"cac": "number[] (10 years, €)",
|
||||||
|
"ltv": "number[] (10 years, €)",
|
||||||
|
"breakEvenDays": "number[] (10 years)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": "string (optional)",
|
||||||
|
"createdAt": "ISO 8601 date string",
|
||||||
|
"updatedAt": "ISO 8601 date string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Relations Between Entities
|
||||||
|
|
||||||
|
### 3.1 Project Relations
|
||||||
|
```
|
||||||
|
Project (1) ──→ (1) TreatmentSite
|
||||||
|
Project (1) ──→ (N) WasteSite
|
||||||
|
Project (1) ──→ (N) AdministrativeProcedure (with status)
|
||||||
|
Project (1) ──→ (N) Investment (with investor reference and status)
|
||||||
|
Project (1) ──→ (1) Waste (optional override)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Treatment Site Relations
|
||||||
|
```
|
||||||
|
TreatmentSite (1) ──→ (N) Service (subscribed services)
|
||||||
|
TreatmentSite (1) ──→ (N) Project
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Waste Site Relations
|
||||||
|
```
|
||||||
|
WasteSite (1) ──→ (1) Waste (waste type)
|
||||||
|
WasteSite (N) ──→ (1) Project
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Service Relations
|
||||||
|
```
|
||||||
|
Service (N) ──→ (1) TreatmentSite (subscribed)
|
||||||
|
Service (N) ──→ (N) Project (used in business plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Investor Relations
|
||||||
|
```
|
||||||
|
Investor (1) ──→ (N) Investment (in projects)
|
||||||
|
Investor (1) ──→ (N) Waste (waste type preferences)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Administrative Procedure Relations
|
||||||
|
```
|
||||||
|
AdministrativeProcedure (1) ──→ (N) Project (with status per project)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Data Validation Rules
|
||||||
|
|
||||||
|
### 4.1 Required Fields
|
||||||
|
- All entities must have: `id`, `createdAt`
|
||||||
|
- Projects must have: `name`, `startDate`, `endDate`, `treatmentSiteId`, `numberOfModules`
|
||||||
|
- Wastes must have: `name`, `bmp`, `waterPercentage`
|
||||||
|
- Services must have: `name`, `type`, `pricing` (all 10 years)
|
||||||
|
|
||||||
|
### 4.2 Value Constraints
|
||||||
|
- `waterPercentage`: 0-100
|
||||||
|
- `bmp`: > 0
|
||||||
|
- `numberOfModules`: > 0
|
||||||
|
- `startDate` < `endDate`
|
||||||
|
- All monetary values: >= 0
|
||||||
|
- All quantities: >= 0
|
||||||
|
|
||||||
|
### 4.3 Reference Integrity
|
||||||
|
- `treatmentSiteId` must exist in `treatmentSites`
|
||||||
|
- `collectionSiteIds` must exist in `wasteSites`
|
||||||
|
- `wasteId` in wasteCharacteristicsOverride must exist in `wastes`
|
||||||
|
- All `procedureId` must exist in `administrativeProcedures`
|
||||||
|
- All `investorId` must exist in `investors`
|
||||||
|
- All `serviceId` in subscribedServices must exist in `services`
|
||||||
|
|
||||||
|
## 5. Import/Export Format
|
||||||
|
|
||||||
|
### 5.1 Export
|
||||||
|
- Export complete storage structure as JSON
|
||||||
|
- Include all entities
|
||||||
|
- Include version number
|
||||||
|
- Include lastModified timestamp
|
||||||
|
|
||||||
|
### 5.2 Import
|
||||||
|
- Replace entire storage with imported JSON
|
||||||
|
- Validate structure
|
||||||
|
- Validate references
|
||||||
|
- Validate constraints
|
||||||
|
- If validation fails, keep existing data and show error
|
||||||
|
|
||||||
|
### 5.3 Import Validation
|
||||||
|
1. Check JSON structure validity
|
||||||
|
2. Check version compatibility
|
||||||
|
3. Validate all required fields
|
||||||
|
4. Validate all value constraints
|
||||||
|
5. Validate all reference integrity
|
||||||
|
6. If any validation fails, reject import and show detailed errors
|
||||||
|
|
||||||
|
## 6. Data Storage Keys
|
||||||
|
|
||||||
|
### 6.1 LocalStorage Keys
|
||||||
|
```
|
||||||
|
"4nkwaste_simulator_data" - Complete application data (JSON string)
|
||||||
|
"4nkwaste_simulator_user" - Current user session
|
||||||
|
"4nkwaste_simulator_version" - Data version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 IndexedDB Structure (if used)
|
||||||
|
```
|
||||||
|
Database: "4nkwaste_simulator"
|
||||||
|
- ObjectStore: "wastes"
|
||||||
|
- ObjectStore: "regulators"
|
||||||
|
- ObjectStore: "services"
|
||||||
|
- ObjectStore: "treatmentSites"
|
||||||
|
- ObjectStore: "wasteSites"
|
||||||
|
- ObjectStore: "investors"
|
||||||
|
- ObjectStore: "administrativeProcedures"
|
||||||
|
- ObjectStore: "projects"
|
||||||
|
- ObjectStore: "users"
|
||||||
|
```
|
||||||
507
formulas_reference.md
Normal file
507
formulas_reference.md
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
# 4NK Waste & Water - Complete Calculation Formulas Reference
|
||||||
|
|
||||||
|
## 1. Infrastructure Capacity Calculations
|
||||||
|
|
||||||
|
### 1.1 Daily Processing Capacity
|
||||||
|
```
|
||||||
|
Daily_processing_capacity (T/day) = Module_capacity (T) × Number_of_modules
|
||||||
|
Daily_processing_capacity = 67T × 21 = 1,407T/day
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Water Content Calculation
|
||||||
|
```
|
||||||
|
Water_content (T) = Total_waste (T) × Water_percentage (%)
|
||||||
|
Water_content = 67T × 75% = 50.25T water per module
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Total Containers
|
||||||
|
```
|
||||||
|
Total_containers = Number_of_modules × Containers_per_module
|
||||||
|
Total_containers = 21 × 4 = 84 containers
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Processing Time Calculations
|
||||||
|
|
||||||
|
### 2.1 Mesophilic Digestion Duration
|
||||||
|
```
|
||||||
|
Mesophilic_duration (days) = Hygienization_duration + Additional_days
|
||||||
|
Mesophilic_duration = 18 days + 3 days = 21 days
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Drying and Bioremediation Duration
|
||||||
|
```
|
||||||
|
Drying_bioremediation_duration (days) = Drying_duration + (Bioremediation_phases × Phase_duration)
|
||||||
|
Drying_bioremediation_duration = 21 days + (3 phases × 21 days) = 84 days (variable)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Thermophilic Digestion and Composting Duration
|
||||||
|
```
|
||||||
|
Thermophilic_composting_duration (days) = Thermophilic_duration + Composting_duration
|
||||||
|
Thermophilic_composting_duration = 18 days + 3 days = 21 days
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Spirulina Cycle Duration
|
||||||
|
```
|
||||||
|
Spirulina_cycle_duration (hours) = 72 hours
|
||||||
|
Spirulina_cycle_duration (days) = 72 / 24 = 3 days
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Methane Production Calculations
|
||||||
|
|
||||||
|
### 3.1 Dry Matter Calculation (VS - Volatile Solids)
|
||||||
|
```
|
||||||
|
Dry_matter (kg VS) = Waste_quantity (kg) × (1 - Water_percentage (%))
|
||||||
|
Dry_matter_percentage (%) = 100% - Water_percentage (%)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
- Waste quantity: 67,000 kg (67T)
|
||||||
|
- Water percentage: 75%
|
||||||
|
- Dry matter: 67,000 × (1 - 0.75) = 67,000 × 0.25 = 16,750 kg VS
|
||||||
|
- Dry matter percentage: 100% - 75% = 25%
|
||||||
|
|
||||||
|
### 3.2 Methane Production from BMP
|
||||||
|
```
|
||||||
|
Methane_production (m³/day) = BMP (Nm³ CH₄/kg VS) × Dry_matter (kg VS/day) × Efficiency_factor
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- BMP: Biochemical Methane Potential (Nm³ CH₄/kg VS) - varies by waste type
|
||||||
|
- Dry_matter: Calculated from waste quantity and water percentage
|
||||||
|
- Efficiency_factor: Processing efficiency (typically 0.7-0.9)
|
||||||
|
|
||||||
|
### 3.3 Origin Units to Methane Conversion
|
||||||
|
```
|
||||||
|
Methane_per_1000m³ = Number_of_origin_units × Conversion_factor
|
||||||
|
```
|
||||||
|
**Parameter**: Number of origin units to produce 1000m³ of methane (varies by waste origin)
|
||||||
|
|
||||||
|
## 4. Gas Production Calculations
|
||||||
|
|
||||||
|
### 4.1 Biogas Composition
|
||||||
|
```
|
||||||
|
Biogas_total (m³/day) = Methane_production (m³/day) / Methane_percentage_in_biogas
|
||||||
|
```
|
||||||
|
|
||||||
|
**Biogas Composition**:
|
||||||
|
- Methane (CH₄): 40% of biogas
|
||||||
|
- CO₂: 60% of biogas
|
||||||
|
|
||||||
|
### 4.2 Methane from Biogas
|
||||||
|
```
|
||||||
|
Methane_production (m³/day) = Biogas_total (m³/day) × 0.40
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 CO₂ Production from Biogas
|
||||||
|
```
|
||||||
|
CO2_production (m³/day) = Biogas_total (m³/day) × 0.60
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative calculation**:
|
||||||
|
```
|
||||||
|
CO2_production (m³/day) = Methane_production (m³/day) × (0.60 / 0.40)
|
||||||
|
CO2_production (m³/day) = Methane_production (m³/day) × 1.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Total Gas Production
|
||||||
|
```
|
||||||
|
Total_gas_production (m³/day) = Methane_production (m³/day) + CO2_production (m³/day)
|
||||||
|
Total_gas_production (m³/day) = Biogas_total (m³/day)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Energy Calculations
|
||||||
|
|
||||||
|
### 5.1 Heat Energy from Biogas
|
||||||
|
```
|
||||||
|
Heat_energy (kJ/day) = Methane_production (m³/day) × Methane_energy_content (kJ/m³) × Combustion_efficiency
|
||||||
|
```
|
||||||
|
**Parameters**:
|
||||||
|
- Methane_energy_content: 35,800 kJ/m³ (lower heating value)
|
||||||
|
- Combustion_efficiency: 0.90 (90%)
|
||||||
|
|
||||||
|
### 5.2 Heat Energy Conversion to kWh
|
||||||
|
```
|
||||||
|
Heat_energy (kW.h/day) = Heat_energy (kJ/day) / 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Electrical Power from Biogas Generator
|
||||||
|
```
|
||||||
|
Electrical_power_biogas (kW) = Methane_production (m³/day) × Methane_energy_content (kJ/m³) × Electrical_efficiency / (3600 × 24)
|
||||||
|
```
|
||||||
|
**Parameter**: Electrical_efficiency: 0.40 (40% for combined heat and power systems)
|
||||||
|
|
||||||
|
### 5.4 Electrical Power from Solar Panels
|
||||||
|
```
|
||||||
|
Electrical_power_solar (kW) = Solar_panel_surface (m²) × Solar_irradiance (kW/m²) × Panel_efficiency
|
||||||
|
```
|
||||||
|
**Parameters**:
|
||||||
|
- Solar_irradiance: Varies by location and season (typically 0.1-1.0 kW/m²)
|
||||||
|
- Panel_efficiency: Typically 0.15-0.22
|
||||||
|
|
||||||
|
### 5.5 Total Electrical Power
|
||||||
|
```
|
||||||
|
Total_electrical_power (kW) = Electrical_power_biogas (kW) + Electrical_power_solar (kW)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 Module Electrical Consumption
|
||||||
|
```
|
||||||
|
Module_electrical_consumption (kW) = Sum of all equipment consumption:
|
||||||
|
- 1 pump (méthanisation): 0.5 kW
|
||||||
|
- 1 séchoir du gaz: 2.0 kW
|
||||||
|
- 1 compresseur: 3.0 kW
|
||||||
|
- 1 lampe UV-C × 12m: 0.3 kW
|
||||||
|
- 5 racloires électriques × 3: 1.5 kW
|
||||||
|
- 1 pompe (spiruline): 0.5 kW
|
||||||
|
- 5 × 12m LED de culture: 0.6 kW
|
||||||
|
- 3 pompes eau: 1.5 kW
|
||||||
|
- Capteurs: 0.1 kW
|
||||||
|
- 1 serveur: 0.2 kW
|
||||||
|
- 1 borne Starlink: 0.1 kW
|
||||||
|
- 1 tableau élec: 0.1 kW
|
||||||
|
- 1 convertisseur panneaux solaires: 0.1 kW
|
||||||
|
Total per module: ~10.5 kW
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Total_modules_consumption (kW) = Module_electrical_consumption (kW) × Number_of_modules
|
||||||
|
Total_modules_consumption (kW) = 10.5 kW × Number_of_modules
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7 Net Electrical Power
|
||||||
|
```
|
||||||
|
Net_electrical_power (kW) = Total_electrical_power (kW) - Total_modules_consumption (kW)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Bitcoin Mining Calculations
|
||||||
|
|
||||||
|
### 6.1 Number of Flex Miners
|
||||||
|
```
|
||||||
|
Number_of_flex_miners = Available_electrical_power (kW) / Power_per_miner (kW)
|
||||||
|
Number_of_flex_miners = Available_electrical_power (kW) / 2 kW
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Bitcoin Production
|
||||||
|
```
|
||||||
|
Bitcoins_BTC_per_year = 79.2 × 0.0001525 / flex_miner
|
||||||
|
```
|
||||||
|
**Formula**: `BTC/year = 79.2 × 0.0001525 / number_of_flex_miners`
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- 79.2: Constant factor
|
||||||
|
- 0.0001525: BTC per flex miner factor
|
||||||
|
- flex_miner: Number of 4NK flex miners (2kW each)
|
||||||
|
|
||||||
|
### 6.3 Bitcoin Value
|
||||||
|
```
|
||||||
|
Bitcoin_value (€) = Bitcoin_quantity (BTC) × Bitcoin_price (€/BTC)
|
||||||
|
```
|
||||||
|
**Parameter**: Bitcoin_price = 100,000 €/BTC
|
||||||
|
|
||||||
|
## 7. Material Output Calculations
|
||||||
|
|
||||||
|
### 7.1 Water Output
|
||||||
|
```
|
||||||
|
Water_output (t/day) = Water_input (t/day) - Water_consumed_in_processes (t/day) + Water_from_spirulina (t/day)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Water Input**:
|
||||||
|
```
|
||||||
|
Water_input (t/day) = Waste_quantity (t/day) × Water_percentage (%)
|
||||||
|
Water_input = 67T × 75% = 50.25T water per module per day
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Fertilizer Production
|
||||||
|
```
|
||||||
|
Fertilizer_output (t/day) = Composting_output (t/day) × Fertilizer_yield_factor
|
||||||
|
```
|
||||||
|
**Parameter**: Fertilizer_yield_factor: 1.0 (100% - all compost becomes standardized fertilizer)
|
||||||
|
|
||||||
|
## 8. Valorization Calculations
|
||||||
|
|
||||||
|
### 8.1 Waste Treatment Valorization
|
||||||
|
```
|
||||||
|
Waste_treatment_valorization (€/year) = Waste_quantity (t/year) × 100 €/t
|
||||||
|
```
|
||||||
|
**Parameter**: 100 €/t
|
||||||
|
|
||||||
|
### 8.2 Fertilizer Valorization
|
||||||
|
```
|
||||||
|
Fertilizer_valorization (€/year) = Fertilizer_quantity (t/year) × 215 €/t
|
||||||
|
```
|
||||||
|
**Parameter**: 215 €/t
|
||||||
|
|
||||||
|
### 8.3 Heat Valorization
|
||||||
|
```
|
||||||
|
Heat_valorization (€/year) = Heat_quantity (t/year) × 0.12 €/t
|
||||||
|
```
|
||||||
|
**Parameter**: 0.12 €/t
|
||||||
|
|
||||||
|
### 8.4 Carbon Equivalent - Burned Methane (CH₄)
|
||||||
|
```
|
||||||
|
CH4_carbon_valorization (€/year) = CH4_quantity (tCO₂e/year) × 172 €/tCO₂e
|
||||||
|
```
|
||||||
|
**Parameters**:
|
||||||
|
- 630 €/tC ≈ 172 €/tCO₂e
|
||||||
|
- Conversion: 1 tC = 3.67 tCO₂e
|
||||||
|
- Formula: `172 = 630 / 3.67`
|
||||||
|
|
||||||
|
### 8.5 Carbon Equivalent - Sequestered CO₂
|
||||||
|
```
|
||||||
|
CO2_carbon_valorization (€/year) = CO2_sequestered (tCO₂e/year) × 27 €/tCO₂e
|
||||||
|
```
|
||||||
|
**Parameters**:
|
||||||
|
- 100 €/tC ≈ 27 €/tCO₂e
|
||||||
|
- Conversion: 1 tC = 3.67 tCO₂e
|
||||||
|
- Formula: `27 = 100 / 3.67`
|
||||||
|
|
||||||
|
### 8.6 Carbon Equivalent - Avoided Electricity Consumption
|
||||||
|
```
|
||||||
|
Energy_carbon_valorization (€/year) = Electricity_avoided (kW/year) × 0.12 €/kW
|
||||||
|
```
|
||||||
|
**Parameter**: 0.12 €/kW
|
||||||
|
|
||||||
|
### 8.7 Land Valorization (Brownfield)
|
||||||
|
```
|
||||||
|
Land_valorization (€) = Brownfield_area (m²) × Valorization_rate (€/m²)
|
||||||
|
```
|
||||||
|
**Parameter**: 4000 m² brownfield (valorization rate configurable)
|
||||||
|
|
||||||
|
## 9. Financial Calculations
|
||||||
|
|
||||||
|
### 9.1 Total Revenues
|
||||||
|
```
|
||||||
|
Total_Revenues (€/year) = Sum of all revenue items:
|
||||||
|
- Raw_rental
|
||||||
|
- Biological_waste_treatment_service
|
||||||
|
- Bitcoin_management_service
|
||||||
|
- Provision_of_standardized_fertilizers_service
|
||||||
|
- Provision_of_waste_heat_service
|
||||||
|
- Provision_of_carbon_credit_indices_service
|
||||||
|
- Brownfield_redevelopment_service
|
||||||
|
- Transport_service
|
||||||
|
- Commercial_partnerships
|
||||||
|
- Other_revenues
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Total Variable Costs
|
||||||
|
```
|
||||||
|
Total_Variable_Costs (€/year) = Sum of all variable cost items:
|
||||||
|
- Rental_and_services
|
||||||
|
- Commissions_intermediaries_import
|
||||||
|
- Other_variable_costs
|
||||||
|
- Transport
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Gross Margin
|
||||||
|
```
|
||||||
|
Gross_Margin (€/year) = Total_Revenues (€/year) - Total_Variable_Costs (€/year)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 Total Fixed Costs (OPEX)
|
||||||
|
```
|
||||||
|
Total_Fixed_Costs (€/year) = Sum of all fixed cost items:
|
||||||
|
- Salaries_and_social_charges
|
||||||
|
- Marketing_communication_expenses
|
||||||
|
- R&D_product_development
|
||||||
|
- Administrative_legal_fees
|
||||||
|
- Other_general_expenses
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.5 Operating Result (EBITDA)
|
||||||
|
```
|
||||||
|
EBITDA (€/year) = Gross_Margin (€/year) - Total_Fixed_Costs (€/year)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.6 Cash Flow
|
||||||
|
```
|
||||||
|
Cash_Flow (€/year) = EBITDA (€/year) - Non_cash_adjustments (€/year) - Working_capital_changes (€/year)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.7 Total Investments (CAPEX)
|
||||||
|
```
|
||||||
|
Total_Investments (€) = Sum of all investment items:
|
||||||
|
- Equipment_machinery
|
||||||
|
- Technology_development
|
||||||
|
- Patents_IP
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.8 Funding Need
|
||||||
|
```
|
||||||
|
Funding_Need (€) = Total_Investments (€) - Available_Cash (€) - Cash_Flow (€)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.9 Use of Raised Funds
|
||||||
|
```
|
||||||
|
Total_Use_of_Funds (€) = Sum of all fund utilization items:
|
||||||
|
- Product_development_POC_MVP
|
||||||
|
- Marketing_customer_acquisition
|
||||||
|
- Team_strengthening_recruitment
|
||||||
|
- Structure_administrative_fees
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Key Performance Indicators (KPIs)
|
||||||
|
|
||||||
|
### 10.1 Customer Acquisition Cost (CAC)
|
||||||
|
```
|
||||||
|
CAC (€) = Marketing_Costs (€) / Number_of_New_Customers
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Lifetime Value (LTV)
|
||||||
|
```
|
||||||
|
LTV (€) = Average_Revenue_per_Customer (€) × Average_Customer_Lifespan (years)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Break-even Point
|
||||||
|
```
|
||||||
|
Break_even_days = Fixed_Costs (€) / (Daily_Revenue (€/day) - Daily_Variable_Costs (€/day))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 Break-even Point (Alternative)
|
||||||
|
```
|
||||||
|
Break_even_days = Fixed_Costs (€) / Daily_Gross_Margin (€/day)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Service Pricing Calculations
|
||||||
|
|
||||||
|
### 11.1 Service Pricing per Module per Year
|
||||||
|
```
|
||||||
|
Service_price_per_module_per_year (€) = Base_price (€) × Module_multiplier
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 Service Pricing over 10 Years
|
||||||
|
```
|
||||||
|
Service_price_10_years (€) = Service_price_per_module_per_year (€) × Number_of_modules × 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 First Year Prototype Pricing
|
||||||
|
```
|
||||||
|
First_year_price (€) = Service_price_per_module_per_year (€) × Prototype_discount_factor
|
||||||
|
```
|
||||||
|
**Parameter**: Prototype_discount_factor: Typically 0.5-0.8 (50-80% of standard price)
|
||||||
|
|
||||||
|
## 12. Conversion Factors
|
||||||
|
|
||||||
|
### 12.1 Energy Conversions
|
||||||
|
```
|
||||||
|
1 kW.h = 3600 kJ
|
||||||
|
1 kJ = 1/3600 kW.h
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 Carbon Conversions
|
||||||
|
```
|
||||||
|
1 tC (tonne of carbon) = 3.67 tCO₂e (tonnes of CO₂ equivalent)
|
||||||
|
1 tCO₂e = 1/3.67 tC ≈ 0.272 tC
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.3 Time Conversions
|
||||||
|
```
|
||||||
|
1 day = 24 hours
|
||||||
|
1 year = 365 days (or 366 for leap years)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 Mass Conversions
|
||||||
|
```
|
||||||
|
1 tonne (t) = 1000 kg
|
||||||
|
1 kg = 0.001 t
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Processing Efficiency Factors
|
||||||
|
|
||||||
|
### 13.1 Anaerobic Digestion Efficiency
|
||||||
|
```
|
||||||
|
Methane_efficiency = Actual_methane_production / Theoretical_methane_production
|
||||||
|
```
|
||||||
|
**Typical range**: 0.70 - 0.90 (70-90%)
|
||||||
|
|
||||||
|
### 13.2 Electrical Conversion Efficiency
|
||||||
|
```
|
||||||
|
Electrical_efficiency = Electrical_power_output / Energy_input
|
||||||
|
```
|
||||||
|
**Typical range**: 0.35 - 0.45 (35-45% for CHP systems)
|
||||||
|
|
||||||
|
### 13.3 Solar Panel Efficiency
|
||||||
|
```
|
||||||
|
Solar_panel_efficiency = Electrical_power_output / (Solar_irradiance × Panel_area)
|
||||||
|
```
|
||||||
|
**Typical range**: 0.15 - 0.22 (15-22%)
|
||||||
|
|
||||||
|
## 14. Water Balance Calculations
|
||||||
|
|
||||||
|
### 14.1 Water Input from Waste
|
||||||
|
```
|
||||||
|
Water_input (t/day) = Waste_quantity (t/day) × Water_percentage (%)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.2 Water Consumption in Processes
|
||||||
|
```
|
||||||
|
Water_consumption (t/day) = Sum of water used in:
|
||||||
|
- Mesophilic_digestion
|
||||||
|
- Drying_process (evaporation)
|
||||||
|
- Bioremediation
|
||||||
|
- Thermophilic_digestion
|
||||||
|
- Composting
|
||||||
|
- Water wall cooling (evaporation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.3 Water from Spirulina Cycle
|
||||||
|
```
|
||||||
|
Spirulina_cycle_duration = 21 days
|
||||||
|
Spirulina_cycles_per_day = 1 / 21 = 0.0476 cycles/day
|
||||||
|
|
||||||
|
Water_from_spirulina (t/day) = Spirulina_cycle_water_output (t/cycle) × Spirulina_cycles_per_day
|
||||||
|
```
|
||||||
|
|
||||||
|
**Spirulina Cycle Management**:
|
||||||
|
- Spirulina culture: 21 days cycle
|
||||||
|
- After 21 days: Water returns to thermophilic anaerobic digestion
|
||||||
|
- Water output from spirulina: Depends on culture volume and evaporation
|
||||||
|
|
||||||
|
### 14.4 Water Evaporation
|
||||||
|
```
|
||||||
|
Water_evaporation (t/day) = Water_wall_surface (m²) × Evaporation_rate (m/day) × Water_density (t/m³)
|
||||||
|
```
|
||||||
|
**Parameters**:
|
||||||
|
- Evaporation_rate: Depends on temperature, humidity, wind (typically 0.001-0.01 m/day)
|
||||||
|
- Water_density: 1 t/m³
|
||||||
|
|
||||||
|
### 14.5 Net Water Output
|
||||||
|
```
|
||||||
|
Net_water_output (t/day) = Water_input (t/day) - Water_consumption (t/day) - Water_evaporation (t/day) + Water_from_spirulina (t/day)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Module and Container Calculations
|
||||||
|
|
||||||
|
### 15.1 Modules per Year
|
||||||
|
```
|
||||||
|
Modules_per_year = Total_modules / Project_duration (years)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15.2 Containers per Module
|
||||||
|
```
|
||||||
|
Containers_per_module = 4 (fixed: mesophilic, drying/bioremediation, thermophilic/composting, water/spirulina)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15.3 Total Container Capacity
|
||||||
|
```
|
||||||
|
Total_container_capacity = Number_of_modules × Containers_per_module × Container_capacity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes on Formula Display
|
||||||
|
|
||||||
|
All formulas must be displayed with:
|
||||||
|
- **Monospace font** (JetBrains Mono, Fira Code, or Courier New)
|
||||||
|
- **Clear variable names** with units
|
||||||
|
- **Parameter values** shown explicitly
|
||||||
|
- **Calculation steps** when applicable
|
||||||
|
- **Input values** used in the calculation
|
||||||
|
- **Result** with appropriate units
|
||||||
|
|
||||||
|
## Formula Validation Rules
|
||||||
|
|
||||||
|
- All input values must be positive (where applicable)
|
||||||
|
- Division by zero must be prevented
|
||||||
|
- Unit conversions must be consistent
|
||||||
|
- Efficiency factors must be between 0 and 1
|
||||||
|
- Percentages must be between 0 and 100
|
||||||
|
- Time values must be positive
|
||||||
|
- Mass/volume values must be positive
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>4NK Waste & Water - Simulator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
378
missing_elements.md
Normal file
378
missing_elements.md
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
# Éléments Manquants pour le Développement - Analyse
|
||||||
|
|
||||||
|
## 1. Stack Technique et Architecture
|
||||||
|
|
||||||
|
### 1.1 Framework et Technologies
|
||||||
|
**Manquant** :
|
||||||
|
- Version précise de React (ou autre framework si différent)
|
||||||
|
- TypeScript ou JavaScript pur ?
|
||||||
|
- Build tool (Vite, Create React App, Webpack ?)
|
||||||
|
- Package manager (npm, yarn, pnpm ?)
|
||||||
|
- Version de Node.js requise
|
||||||
|
|
||||||
|
**Recommandation** : Spécifier la stack technique complète
|
||||||
|
|
||||||
|
### 1.2 Routing
|
||||||
|
**Manquant** :
|
||||||
|
- Bibliothèque de routing (React Router ?)
|
||||||
|
- Structure des routes
|
||||||
|
- Gestion des routes protégées (authentification)
|
||||||
|
- Gestion des 404
|
||||||
|
|
||||||
|
**Recommandation** : Définir le système de routing
|
||||||
|
|
||||||
|
### 1.3 Gestion d'État
|
||||||
|
**Manquant** :
|
||||||
|
- Solution de state management (Context API, Zustand, Redux ?)
|
||||||
|
- Structure des stores/contexts
|
||||||
|
- Comment gérer l'état global vs local
|
||||||
|
- Persistance de l'état dans localStorage
|
||||||
|
|
||||||
|
**Recommandation** : Choisir et documenter la solution de state management
|
||||||
|
|
||||||
|
## 2. Structure de Données Détaillée
|
||||||
|
|
||||||
|
### 2.1 Schémas de Données JSON
|
||||||
|
**Manquant** :
|
||||||
|
- Schémas JSON complets pour chaque entité
|
||||||
|
- Structure exacte des objets stockés
|
||||||
|
- Relations entre entités (comment un projet référence un site)
|
||||||
|
- Versioning des données
|
||||||
|
- Migration de données
|
||||||
|
|
||||||
|
**Recommandation** : Créer des schémas JSON détaillés avec exemples
|
||||||
|
|
||||||
|
### 2.2 Relations entre Entités
|
||||||
|
**Manquant** :
|
||||||
|
- Comment un projet est lié à un site de traitement
|
||||||
|
- Comment un projet est lié à des sites de déchets
|
||||||
|
- Comment les déchets sont associés aux projets
|
||||||
|
- Comment les services sont liés aux projets
|
||||||
|
- Gestion des références (IDs, noms ?)
|
||||||
|
|
||||||
|
**Recommandation** : Définir le modèle de relations et les clés étrangères
|
||||||
|
|
||||||
|
### 2.3 Données Initiales (Seed Data)
|
||||||
|
**Manquant** :
|
||||||
|
- Données de démonstration initiales
|
||||||
|
- Valeurs par défaut pour les configurations
|
||||||
|
- Exemples de projets, sites, déchets
|
||||||
|
- Templates de configuration
|
||||||
|
|
||||||
|
**Recommandation** : Créer des données initiales pour faciliter le développement
|
||||||
|
|
||||||
|
## 3. Logique Métier Détaillée
|
||||||
|
|
||||||
|
### 3.1 Calculs Manquants
|
||||||
|
**Manquant** :
|
||||||
|
- Comment calculer la matière sèche (VS) à partir du % d'eau
|
||||||
|
- Comment calculer le CO₂ à partir du méthane (ratio précis)
|
||||||
|
- Comment calculer la consommation électrique des modules
|
||||||
|
- Comment calculer l'évaporation d'eau
|
||||||
|
- Comment calculer le rendement d'engrais à partir du compost
|
||||||
|
- Facteurs d'efficacité précis pour chaque processus
|
||||||
|
- Comment gérer les cycles de spiruline (retour en méthanisation)
|
||||||
|
|
||||||
|
**Recommandation** : Compléter les formules manquantes dans formulas_reference.md
|
||||||
|
|
||||||
|
### 3.2 Dépendances entre Calculs
|
||||||
|
**Manquant** :
|
||||||
|
- Ordre d'exécution des calculs
|
||||||
|
- Quels calculs dépendent de quels autres
|
||||||
|
- Comment mettre à jour les calculs quand une donnée change
|
||||||
|
- Gestion des calculs en cascade
|
||||||
|
|
||||||
|
**Recommandation** : Créer un graphe de dépendances des calculs
|
||||||
|
|
||||||
|
### 3.3 Validation Métier
|
||||||
|
**Manquant** :
|
||||||
|
- Règles de validation spécifiques (ex: BMP doit être entre X et Y)
|
||||||
|
- Contraintes entre champs (ex: si STEP sludge, alors pas de mélange)
|
||||||
|
- Validation des configurations de projet
|
||||||
|
- Validation des calculs (vérifier que les résultats sont cohérents)
|
||||||
|
|
||||||
|
**Recommandation** : Documenter toutes les règles de validation métier
|
||||||
|
|
||||||
|
## 4. Interface Utilisateur
|
||||||
|
|
||||||
|
### 4.1 Workflow Utilisateur
|
||||||
|
**Manquant** :
|
||||||
|
- Parcours utilisateur complet (user journey)
|
||||||
|
- Ordre recommandé de configuration (déchets → régulateurs → services → projets ?)
|
||||||
|
- Workflow de création d'un projet
|
||||||
|
- Workflow de calcul des rendements
|
||||||
|
- Gestion des erreurs dans les formulaires
|
||||||
|
|
||||||
|
**Recommandation** : Créer des diagrammes de workflow
|
||||||
|
|
||||||
|
### 4.2 États de l'Interface
|
||||||
|
**Manquant** :
|
||||||
|
- États de chargement (skeleton, spinners)
|
||||||
|
- États d'erreur (messages, retry)
|
||||||
|
- États vides (pas de projets, pas de données)
|
||||||
|
- États de succès (confirmations)
|
||||||
|
|
||||||
|
**Recommandation** : Définir tous les états UI possibles
|
||||||
|
|
||||||
|
### 4.3 Interactions
|
||||||
|
**Manquant** :
|
||||||
|
- Comportement des formulaires (sauvegarde auto, validation en temps réel ?)
|
||||||
|
- Comportement des tableaux (tri, filtres, pagination ?)
|
||||||
|
- Comportement des modales (fermeture, annulation)
|
||||||
|
- Gestion du clavier (raccourcis, navigation)
|
||||||
|
|
||||||
|
**Recommandation** : Spécifier les interactions détaillées
|
||||||
|
|
||||||
|
## 5. Gestion des Données
|
||||||
|
|
||||||
|
### 5.1 Export/Import
|
||||||
|
**Manquant** :
|
||||||
|
- Format exact d'export (JSON structure)
|
||||||
|
- Format d'import (validation, migration)
|
||||||
|
- Que faire en cas de conflit lors de l'import
|
||||||
|
- Export partiel (un projet, tous les projets ?)
|
||||||
|
- Versioning des exports
|
||||||
|
|
||||||
|
**Recommandation** : Définir le format d'export/import complet
|
||||||
|
|
||||||
|
### 5.2 Sauvegarde
|
||||||
|
**Manquant** :
|
||||||
|
- Fréquence de sauvegarde automatique
|
||||||
|
- Quand sauvegarder (à chaque modification, sur blur, sur submit ?)
|
||||||
|
- Gestion des conflits (si plusieurs onglets ouverts)
|
||||||
|
- Limite de taille des données
|
||||||
|
- Gestion du quota de stockage
|
||||||
|
|
||||||
|
**Recommandation** : Spécifier la stratégie de sauvegarde
|
||||||
|
|
||||||
|
### 5.3 Migration de Données
|
||||||
|
**Manquant** :
|
||||||
|
- Comment migrer les données si le schéma change
|
||||||
|
- Versioning des données
|
||||||
|
- Scripts de migration
|
||||||
|
- Rétrocompatibilité
|
||||||
|
|
||||||
|
**Recommandation** : Prévoir un système de versioning et migration
|
||||||
|
|
||||||
|
## 6. Authentification
|
||||||
|
|
||||||
|
### 6.1 Détails d'Implémentation
|
||||||
|
**Manquant** :
|
||||||
|
- Où stocker les credentials (localStorage ?)
|
||||||
|
- Format de stockage (hashé ? en clair ?)
|
||||||
|
- Gestion de la session (timeout ?)
|
||||||
|
- Gestion du logout
|
||||||
|
- Gestion de la reconnexion
|
||||||
|
|
||||||
|
**Recommandation** : Spécifier l'implémentation de l'authentification
|
||||||
|
|
||||||
|
### 6.2 Sécurité
|
||||||
|
**Manquant** :
|
||||||
|
- Comment empêcher l'accès depuis un autre host que localhost
|
||||||
|
- Validation côté client (mais pas de backend)
|
||||||
|
- Protection des données sensibles
|
||||||
|
|
||||||
|
**Recommandation** : Documenter les mesures de sécurité
|
||||||
|
|
||||||
|
## 7. Calculs et Rendements
|
||||||
|
|
||||||
|
### 7.1 Paramètres par Défaut
|
||||||
|
**Manquant** :
|
||||||
|
- Valeurs par défaut pour tous les paramètres
|
||||||
|
- Facteurs d'efficacité par défaut
|
||||||
|
- Ratios par défaut (CO₂/CH₄, etc.)
|
||||||
|
- Paramètres de conversion par défaut
|
||||||
|
|
||||||
|
**Recommandation** : Créer un fichier de constantes avec toutes les valeurs par défaut
|
||||||
|
|
||||||
|
### 7.2 Calculs Temporels
|
||||||
|
**Manquant** :
|
||||||
|
- Comment calculer les rendements sur différentes périodes (jour, semaine, mois, année)
|
||||||
|
- Comment agréger les données sur 10 ans pour le business plan
|
||||||
|
- Gestion des années bissextiles
|
||||||
|
- Calculs cumulatifs
|
||||||
|
|
||||||
|
**Recommandation** : Spécifier les calculs temporels
|
||||||
|
|
||||||
|
### 7.3 Calculs Multi-Modules
|
||||||
|
**Manquant** :
|
||||||
|
- Comment agréger les calculs de plusieurs modules
|
||||||
|
- Comment gérer les modules avec des configurations différentes
|
||||||
|
- Calculs par site de traitement (plusieurs modules)
|
||||||
|
|
||||||
|
**Recommandation** : Définir les règles d'agrégation
|
||||||
|
|
||||||
|
## 8. Business Plan
|
||||||
|
|
||||||
|
### 8.1 Calculs Financiers Détaillés
|
||||||
|
**Manquant** :
|
||||||
|
- Comment calculer les ajustements non-cash pour le cash flow
|
||||||
|
- Comment calculer les changements de fonds de roulement
|
||||||
|
- Calculs de dépréciation/amortissement
|
||||||
|
- Calculs d'impôts (si applicable)
|
||||||
|
- Calculs de remboursement de dette (si applicable)
|
||||||
|
|
||||||
|
**Recommandation** : Compléter les formules financières
|
||||||
|
|
||||||
|
### 8.2 Projections sur 10 Ans
|
||||||
|
**Manquant** :
|
||||||
|
- Comment projeter les revenus sur 10 ans (croissance ?)
|
||||||
|
- Comment projeter les coûts sur 10 ans (inflation ?)
|
||||||
|
- Facteurs de croissance/diminution
|
||||||
|
- Scénarios (optimiste, réaliste, pessimiste ?)
|
||||||
|
|
||||||
|
**Recommandation** : Définir les règles de projection
|
||||||
|
|
||||||
|
## 9. Configuration et Paramètres
|
||||||
|
|
||||||
|
### 9.1 Paramètres Configurables
|
||||||
|
**Manquant** :
|
||||||
|
- Liste complète des paramètres configurables
|
||||||
|
- Paramètres globaux vs paramètres par projet
|
||||||
|
- Paramètres par site
|
||||||
|
- Hiérarchie des paramètres (global → site → projet)
|
||||||
|
|
||||||
|
**Recommandation** : Créer une liste exhaustive des paramètres
|
||||||
|
|
||||||
|
### 9.2 Constantes
|
||||||
|
**Manquant** :
|
||||||
|
- Toutes les constantes physiques (énergie du méthane, etc.)
|
||||||
|
- Constantes de conversion
|
||||||
|
- Limites et contraintes (min/max)
|
||||||
|
- Unités de mesure standardisées
|
||||||
|
|
||||||
|
**Recommandation** : Créer un fichier de constantes complet
|
||||||
|
|
||||||
|
## 10. Gestion des Erreurs
|
||||||
|
|
||||||
|
### 10.1 Types d'Erreurs
|
||||||
|
**Manquant** :
|
||||||
|
- Erreurs de validation
|
||||||
|
- Erreurs de calcul (division par zéro, valeurs négatives)
|
||||||
|
- Erreurs de stockage (quota dépassé)
|
||||||
|
- Erreurs d'import/export
|
||||||
|
- Messages d'erreur utilisateur
|
||||||
|
|
||||||
|
**Recommandation** : Documenter tous les types d'erreurs et leurs messages
|
||||||
|
|
||||||
|
### 10.2 Gestion des Erreurs
|
||||||
|
**Manquant** :
|
||||||
|
- Comment afficher les erreurs
|
||||||
|
- Comment récupérer d'une erreur
|
||||||
|
- Logging des erreurs (console ?)
|
||||||
|
- Validation en temps réel vs à la soumission
|
||||||
|
|
||||||
|
**Recommandation** : Définir la stratégie de gestion d'erreurs
|
||||||
|
|
||||||
|
## 11. Tests
|
||||||
|
|
||||||
|
### 11.1 Stratégie de Tests
|
||||||
|
**Manquant** :
|
||||||
|
- Framework de tests (Jest, Vitest ?)
|
||||||
|
- Tests unitaires (quelles fonctions tester)
|
||||||
|
- Tests d'intégration
|
||||||
|
- Tests E2E (si applicable)
|
||||||
|
- Couverture de code attendue
|
||||||
|
|
||||||
|
**Recommandation** : Définir la stratégie de tests
|
||||||
|
|
||||||
|
### 11.2 Données de Test
|
||||||
|
**Manquant** :
|
||||||
|
- Jeux de données de test
|
||||||
|
- Cas limites à tester
|
||||||
|
- Scénarios de test
|
||||||
|
|
||||||
|
**Recommandation** : Créer des données de test
|
||||||
|
|
||||||
|
## 12. Documentation Technique
|
||||||
|
|
||||||
|
### 12.1 Documentation Développeur
|
||||||
|
**Manquant** :
|
||||||
|
- Guide de démarrage
|
||||||
|
- Architecture technique détaillée
|
||||||
|
- Guide de contribution
|
||||||
|
- Conventions de code
|
||||||
|
- Structure des dossiers
|
||||||
|
|
||||||
|
**Recommandation** : Créer une documentation technique
|
||||||
|
|
||||||
|
### 12.2 Documentation Utilisateur
|
||||||
|
**Manquant** :
|
||||||
|
- Guide utilisateur
|
||||||
|
- Tutoriels
|
||||||
|
- FAQ
|
||||||
|
- Aide contextuelle
|
||||||
|
|
||||||
|
**Recommandation** : Prévoir une documentation utilisateur
|
||||||
|
|
||||||
|
## 13. Déploiement et Distribution
|
||||||
|
|
||||||
|
### 13.1 Build et Distribution
|
||||||
|
**Manquant** :
|
||||||
|
- Processus de build
|
||||||
|
- Comment distribuer l'application (fichiers statiques ?)
|
||||||
|
- Comment l'utilisateur installe/lance l'application
|
||||||
|
- Serveur local nécessaire ?
|
||||||
|
|
||||||
|
**Recommandation** : Spécifier le processus de déploiement
|
||||||
|
|
||||||
|
### 13.2 Configuration d'Environnement
|
||||||
|
**Manquant** :
|
||||||
|
- Variables d'environnement
|
||||||
|
- Configuration de développement vs production
|
||||||
|
- Fichiers de configuration
|
||||||
|
|
||||||
|
**Recommandation** : Définir la configuration d'environnement
|
||||||
|
|
||||||
|
## 14. Accessibilité et Internationalisation
|
||||||
|
|
||||||
|
### 14.1 Accessibilité
|
||||||
|
**Manquant** :
|
||||||
|
- Niveau d'accessibilité requis (WCAG AA ?)
|
||||||
|
- Tests d'accessibilité
|
||||||
|
- Support des lecteurs d'écran
|
||||||
|
|
||||||
|
**Recommandation** : Spécifier les exigences d'accessibilité
|
||||||
|
|
||||||
|
### 14.2 Internationalisation
|
||||||
|
**Manquant** :
|
||||||
|
- Langues supportées (français uniquement ?)
|
||||||
|
- Format des dates/nombres
|
||||||
|
- Format des devises
|
||||||
|
|
||||||
|
**Recommandation** : Définir les besoins d'internationalisation
|
||||||
|
|
||||||
|
## 15. Performance
|
||||||
|
|
||||||
|
### 15.1 Contraintes de Performance
|
||||||
|
**Manquant** :
|
||||||
|
- Temps de chargement acceptable
|
||||||
|
- Temps de calcul acceptable
|
||||||
|
- Taille maximale des données
|
||||||
|
- Nombre maximum de projets/modules
|
||||||
|
|
||||||
|
**Recommandation** : Définir les objectifs de performance (même sans optimisation)
|
||||||
|
|
||||||
|
## Priorités Recommandées
|
||||||
|
|
||||||
|
### Priorité Haute (Blocant pour le développement)
|
||||||
|
1. Stack technique complet
|
||||||
|
2. Schémas de données JSON
|
||||||
|
3. Relations entre entités
|
||||||
|
4. Calculs manquants
|
||||||
|
5. Paramètres par défaut et constantes
|
||||||
|
6. Structure de projet
|
||||||
|
|
||||||
|
### Priorité Moyenne (Important pour la qualité)
|
||||||
|
1. Gestion d'état
|
||||||
|
2. Routing
|
||||||
|
3. Workflow utilisateur
|
||||||
|
4. Validation métier
|
||||||
|
5. Export/Import format
|
||||||
|
6. Gestion des erreurs
|
||||||
|
|
||||||
|
### Priorité Basse (Amélioration continue)
|
||||||
|
1. Tests
|
||||||
|
2. Documentation
|
||||||
|
3. Accessibilité
|
||||||
|
4. Internationalisation
|
||||||
1729
package-lock.json
generated
Normal file
1729
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "4nk-waste-simulator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "4NK Waste & Water - Modular Waste Treatment Infrastructure Simulator",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
1789
specification.md
Normal file
1789
specification.md
Normal file
File diff suppressed because it is too large
Load Diff
56
src/App.tsx
Normal file
56
src/App.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from './contexts/AuthContext'
|
||||||
|
import Layout from './components/layout/Layout'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import WasteConfigurationPage from './pages/configuration/WasteConfigurationPage'
|
||||||
|
import RegulatorsConfigurationPage from './pages/configuration/RegulatorsConfigurationPage'
|
||||||
|
import ServicesConfigurationPage from './pages/configuration/ServicesConfigurationPage'
|
||||||
|
import ProjectListPage from './pages/projects/ProjectListPage'
|
||||||
|
import ProjectConfigurationPage from './pages/projects/ProjectConfigurationPage'
|
||||||
|
import TreatmentSitesPage from './pages/projects/TreatmentSitesPage'
|
||||||
|
import WasteSitesPage from './pages/projects/WasteSitesPage'
|
||||||
|
import InvestorsPage from './pages/projects/InvestorsPage'
|
||||||
|
import AdministrativeProceduresPage from './pages/projects/AdministrativeProceduresPage'
|
||||||
|
import YieldsPage from './pages/YieldsPage'
|
||||||
|
import BusinessPlanPage from './pages/BusinessPlanPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import HelpPage from './pages/HelpPage'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/configuration/waste" element={<WasteConfigurationPage />} />
|
||||||
|
<Route path="/configuration/regulators" element={<RegulatorsConfigurationPage />} />
|
||||||
|
<Route path="/configuration/services" element={<ServicesConfigurationPage />} />
|
||||||
|
<Route path="/projects" element={<ProjectListPage />} />
|
||||||
|
<Route path="/projects/new" element={<ProjectConfigurationPage />} />
|
||||||
|
<Route path="/projects/:id" element={<ProjectConfigurationPage />} />
|
||||||
|
<Route path="/projects/treatment-sites" element={<TreatmentSitesPage />} />
|
||||||
|
<Route path="/projects/waste-sites" element={<WasteSitesPage />} />
|
||||||
|
<Route path="/projects/investors" element={<InvestorsPage />} />
|
||||||
|
<Route path="/projects/procedures" element={<AdministrativeProceduresPage />} />
|
||||||
|
<Route path="/yields" element={<YieldsPage />} />
|
||||||
|
<Route path="/business-plan" element={<BusinessPlanPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/help" element={<HelpPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
62
src/components/base/Badge.css
Normal file
62
src/components/base/Badge.css
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: rgba(16, 185, 129, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: rgba(245, 158, 11, 0.2);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background-color: rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background-color: rgba(245, 158, 11, 0.2);
|
||||||
|
color: var(--pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-completed {
|
||||||
|
background-color: rgba(16, 185, 129, 0.2);
|
||||||
|
color: var(--completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-to-do {
|
||||||
|
background-color: rgba(108, 117, 125, 0.2);
|
||||||
|
color: var(--to-do);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-to-be-approached {
|
||||||
|
background-color: rgba(245, 158, 11, 0.2);
|
||||||
|
color: var(--to-be-approached);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-loi-ok {
|
||||||
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
|
color: var(--loi-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-in-progress {
|
||||||
|
background-color: rgba(139, 92, 246, 0.2);
|
||||||
|
color: var(--in-progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-na {
|
||||||
|
background-color: rgba(156, 163, 175, 0.2);
|
||||||
|
color: var(--na);
|
||||||
|
}
|
||||||
10
src/components/base/Badge.tsx
Normal file
10
src/components/base/Badge.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import './Badge.css'
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
children: string
|
||||||
|
variant?: 'success' | 'warning' | 'error' | 'info' | 'pending' | 'completed' | 'to-do' | 'to-be-approached' | 'loi-ok' | 'in-progress' | 'na'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Badge({ children, variant = 'info' }: BadgeProps) {
|
||||||
|
return <span className={`badge badge-${variant}`}>{children}</span>
|
||||||
|
}
|
||||||
49
src/components/base/Button.css
Normal file
49
src/components/base/Button.css
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
.button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 200ms ease-in-out;
|
||||||
|
border: none;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background-color: var(--primary-green);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover {
|
||||||
|
background-color: var(--primary-green-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:active {
|
||||||
|
background-color: var(--primary-green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
background-color: var(--error);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger:hover {
|
||||||
|
background-color: #DC2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
15
src/components/base/Button.tsx
Normal file
15
src/components/base/Button.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { ReactNode, ButtonHTMLAttributes } from 'react'
|
||||||
|
import './Button.css'
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger'
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({ variant = 'primary', children, className = '', ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button className={`button button-${variant} ${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/components/base/Card.css
Normal file
20
src/components/base/Card.css
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
.card {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
17
src/components/base/Card.tsx
Normal file
17
src/components/base/Card.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import './Card.css'
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card({ children, title, className = '' }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`card ${className}`}>
|
||||||
|
{title && <div className="card-header">{title}</div>}
|
||||||
|
<div className="card-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/components/base/Input.css
Normal file
60
src/components/base/Input.css
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
.input-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
transition: border-color 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error:focus {
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error-message {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--error);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-help-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
33
src/components/base/Input.tsx
Normal file
33
src/components/base/Input.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { InputHTMLAttributes, forwardRef } from 'react'
|
||||||
|
import './Input.css'
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
helpText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, helpText, className = '', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="input-group">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={props.id} className="input-label">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={`input ${error ? 'input-error' : ''} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <div className="input-error-message">{error}</div>}
|
||||||
|
{helpText && !error && <div className="input-help-text">{helpText}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export default Input
|
||||||
57
src/components/base/Select.css
Normal file
57
src/components/base/Select.css
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
.select-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:disabled {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-error {
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-error:focus {
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-error-message {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--error);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-help-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
40
src/components/base/Select.tsx
Normal file
40
src/components/base/Select.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { SelectHTMLAttributes, forwardRef } from 'react'
|
||||||
|
import './Select.css'
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
helpText?: string
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ label, error, helpText, options, className = '', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="select-group">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={props.id} className="select-label">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={`select ${error ? 'select-error' : ''} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <div className="select-error-message">{error}</div>}
|
||||||
|
{helpText && !error && <div className="select-help-text">{helpText}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Select.displayName = 'Select'
|
||||||
|
|
||||||
|
export default Select
|
||||||
46
src/components/base/Table.css
Normal file
46
src/components/base/Table.css
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
.table-container {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row:hover {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cell {
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-empty {
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
51
src/components/base/Table.tsx
Normal file
51
src/components/base/Table.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import './Table.css'
|
||||||
|
|
||||||
|
interface TableColumn<T> {
|
||||||
|
key: string
|
||||||
|
header: string
|
||||||
|
render: (item: T) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableProps<T> {
|
||||||
|
columns: TableColumn<T>[]
|
||||||
|
data: T[]
|
||||||
|
emptyMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Table<T extends { id: string }>({ columns, data, emptyMessage = 'No data' }: TableProps<T>) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="table-empty">
|
||||||
|
<p>{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column.key} className="table-header">
|
||||||
|
{column.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<tr key={item.id} className="table-row">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.key} className="table-cell">
|
||||||
|
{column.render(item)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
src/components/layout/Header.css
Normal file
72
src/components/layout/Header.css
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
.header {
|
||||||
|
height: 64px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--spacing-xl);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-project-selector {
|
||||||
|
background-color: var(--background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-project-selector:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-username {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logout {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logout:hover {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
44
src/components/layout/Header.tsx
Normal file
44
src/components/layout/Header.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import './Header.css'
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const { username, logout } = useAuth()
|
||||||
|
const { data } = useStorage()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const projects = data?.projects || []
|
||||||
|
const currentProject = projects.length > 0 ? projects[0] : null
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-left">
|
||||||
|
<h1 className="header-logo">4NK Waste & Water</h1>
|
||||||
|
</div>
|
||||||
|
<div className="header-center">
|
||||||
|
{currentProject && (
|
||||||
|
<select className="header-project-selector">
|
||||||
|
<option value={currentProject.id}>{currentProject.name}</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
<span className="header-username">{username}</span>
|
||||||
|
<button onClick={handleLogout} className="header-logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/components/layout/Layout.css
Normal file
18
src/components/layout/Layout.css
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-left: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
22
src/components/layout/Layout.tsx
Normal file
22
src/components/layout/Layout.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
import Header from './Header'
|
||||||
|
import './Layout.css'
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="layout-content">
|
||||||
|
<Header />
|
||||||
|
<main className="layout-main">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/components/layout/Sidebar.css
Normal file
71
src/components/layout/Sidebar.css
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 256px;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 200ms ease-in-out;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.active {
|
||||||
|
background-color: var(--primary-green);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-left-color: var(--primary-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
76
src/components/layout/Sidebar.tsx
Normal file
76
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import './Sidebar.css'
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<NavLink to="/" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">🏠</span>
|
||||||
|
<span className="sidebar-label">Dashboard</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section-title">Configuration</div>
|
||||||
|
<NavLink to="/configuration/waste" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">🗑️</span>
|
||||||
|
<span className="sidebar-label">Waste</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/configuration/regulators" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">🌿</span>
|
||||||
|
<span className="sidebar-label">Regulators</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/configuration/services" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">💼</span>
|
||||||
|
<span className="sidebar-label">Services</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section-title">Projects</div>
|
||||||
|
<NavLink to="/projects" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">📁</span>
|
||||||
|
<span className="sidebar-label">Projects</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/projects/treatment-sites" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">🏭</span>
|
||||||
|
<span className="sidebar-label">Treatment Sites</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/projects/waste-sites" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">🚚</span>
|
||||||
|
<span className="sidebar-label">Waste Sites</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/projects/investors" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">👥</span>
|
||||||
|
<span className="sidebar-label">Investors</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/projects/procedures" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">📄</span>
|
||||||
|
<span className="sidebar-label">Procedures</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NavLink to="/yields" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">📊</span>
|
||||||
|
<span className="sidebar-label">Yields</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink to="/business-plan" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">💰</span>
|
||||||
|
<span className="sidebar-label">Business Plan</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<div className="sidebar-section sidebar-section-bottom">
|
||||||
|
<NavLink to="/settings" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">⚙️</span>
|
||||||
|
<span className="sidebar-label">Settings</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/help" className="sidebar-item">
|
||||||
|
<span className="sidebar-icon">❓</span>
|
||||||
|
<span className="sidebar-label">Help</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/contexts/AuthContext.tsx
Normal file
55
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
|
import { getUserSession, saveUserSession, clearUserSession } from '@/utils/storage'
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
username: string | null
|
||||||
|
login: (user: string, password: string) => boolean
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [username, setUsername] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionUser = getUserSession()
|
||||||
|
if (sessionUser) {
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setUsername(sessionUser)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = (user: string, password: string): boolean => {
|
||||||
|
if (user && user.trim() && password && password.trim()) {
|
||||||
|
saveUserSession(user.trim())
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setUsername(user.trim())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
clearUserSession()
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
setUsername(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ isAuthenticated, username, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
863
src/data/seedRegulators.ts
Normal file
863
src/data/seedRegulators.ts
Normal file
@ -0,0 +1,863 @@
|
|||||||
|
import { NaturalRegulator } from '@/types'
|
||||||
|
|
||||||
|
export const seedRegulators: NaturalRegulator[] = [
|
||||||
|
// ===== RÉGULATEURS PHYSICO-CHIMIQUES COMMUNS =====
|
||||||
|
{
|
||||||
|
id: 'reg-001',
|
||||||
|
name: 'Gypse',
|
||||||
|
type: 'physico-chimique-mineral',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: -0.5, // Réduction douce du pH
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Poudre ou granulé sec à doser en fonction du taux de salinité mesuré ; préhydratation facultative. Température: 5-70°C, Humidité: 30-95%. Mélangé à cœur ou en surface (≥ 20 cm).',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 5,
|
||||||
|
max: 50,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-002',
|
||||||
|
name: 'Biochar',
|
||||||
|
type: 'support-adsorbant',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: true,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Sec, broyé ou granulé fin ; intégré par brassage ou dispersion ciblée. Température: 0-70°C, Humidité: 10-70%. Dispersé sur 10-30 cm de profondeur.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-003',
|
||||||
|
name: 'Aiguilles de pin',
|
||||||
|
type: 'substrat-vegetal-acidifiant',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: -1.0,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Sec ou légèrement humidifié, pré-broyé si possible, en mélange progressif. Température: 5-60°C, Humidité: 15-85%. 10 à 25 cm de dispersion ou paillage superficiel.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 20,
|
||||||
|
max: 80,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-004',
|
||||||
|
name: 'Cendres',
|
||||||
|
type: 'regulateur-mineral-alcalin',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: 1.5,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Sec, broyé fin, incorporé lentement ; dosage en fonction du pH du digesta. Température: 5-70°C, Humidité: 10-70%. Intégration homogène à 10-30 cm.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 5,
|
||||||
|
max: 30,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-005',
|
||||||
|
name: 'CO₂ (générateur ou apport externe)',
|
||||||
|
type: 'regulateur-gazeux-tampon',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: -0.3,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Injection contrôlée dans digesteur fermé ; surveillance manométrique. Température: 5-70°C. Mélange dans la phase gazeuse du digesteur.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-006',
|
||||||
|
name: 'H₂ (gaz)',
|
||||||
|
type: 'gaz-reducteur',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Injection à faible débit dans environnement anaérobie strict, à température stabilisée. Température: 15-65°C. Mélange dans la zone supérieure du digesteur.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-007',
|
||||||
|
name: 'Déchets STEP sableux',
|
||||||
|
type: 'support-bacterien-inerte',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Sable sec ou semi-sec, intégré en phase de remplissage de cuve. Température: 5-60°C, Humidité: 10-80%. Couche basale ou mélange 10-30 cm.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 50,
|
||||||
|
max: 200,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-008',
|
||||||
|
name: 'Glucose (fruits)',
|
||||||
|
type: 'substrat-biochimique-soluble',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Incorporé en solution ou purée, bien mélangé dans les premières 24h. Température: 10-65°C, Humidité: 60-98%. 5 à 15 cm selon homogénéité du mélange.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-009',
|
||||||
|
name: 'Digesta (de lot précédent)',
|
||||||
|
type: 'residu-biologique-stabilise',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Utilisation rapide après extraction ; ajout en mélange homogène. Température: 15-65°C, Humidité: 75-98%. 10 à 25 cm en mélange avec substrat frais.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 5,
|
||||||
|
max: 20,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== BACTÉRIES COMMUNES À TOUTES LES MÉTHANISATIONS =====
|
||||||
|
{
|
||||||
|
id: 'reg-010',
|
||||||
|
name: 'Clostridium butyricum',
|
||||||
|
type: 'bacterie-anaerobie-fermentative',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Inoculation sous atmosphère anaérobie stricte, dans substrat riche en glucides. Température: 20-55°C, Humidité: 75-98%. Uniformément répartie, optimale à ≥ 15 cm.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-011',
|
||||||
|
name: 'Clostridium acetobutylicum',
|
||||||
|
type: 'bacterie-anaerobie-fermentative',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Apport en substrat carboné (glucose, amidon), maintien d\'un pH neutre. Température: 25-55°C, Humidité: 70-98%. ≥ 20 cm dans la phase active du digesta.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-012',
|
||||||
|
name: 'Enterobacter aerogenes',
|
||||||
|
type: 'bacterie-anaerobie-facultative',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Préfère des substrats sucrés, peut cohabiter temporairement avec O₂ résiduel. Température: 20-50°C, Humidité: 70-99%. Surface à profondeur moyenne (10-25 cm).',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 1.5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-013',
|
||||||
|
name: 'Desulfobacter postgatei',
|
||||||
|
type: 'bacterie-anaerobie-reductrice-sulfate',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Anaérobie stricte, apport modéré en carbone ; éviter les excès de nitrates. Température: 15-55°C, Humidité: 80-100%. ≥ 30 cm, dans les zones profondes.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.05,
|
||||||
|
max: 1,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-014',
|
||||||
|
name: 'Lactobacillus spp.',
|
||||||
|
type: 'bacterie-fermentative-lactique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: -1.0,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: true,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Inoculation au démarrage ou en phase de relance ; bon brassage nécessaire. Température: 10-50°C, Humidité: 60-98%. 5 à 20 cm, zone supérieure du substrat.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.2,
|
||||||
|
max: 3,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-015',
|
||||||
|
name: 'Bacillus subtilis',
|
||||||
|
type: 'bacterie-sporulante-multifonctionnelle',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: true,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Peut être inoculé dès la phase mésophile, tolère les transitions thermiques. Température: 15-60°C, Humidité: 50-95%. 10 à 25 cm selon brassage.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-016',
|
||||||
|
name: 'Enterococcus faecium',
|
||||||
|
type: 'bacterie-fermentative-lactique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: -0.8,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: true,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Apport en début de cycle, co-inoculation avec lactobacilles possible. Température: 10-45°C, Humidité: 65-98%. 10 à 20 cm dans la zone active.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 1.5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-017',
|
||||||
|
name: 'Paenibacillus polymyxa',
|
||||||
|
type: 'bacterie-fixatrice-azote',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
nitrogen: 0.5,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Inoculation dans substrats végétaux ou mixtes ; pH neutre à légèrement acide. Température: 15-50°C, Humidité: 55-95%. 15 à 30 cm ; zones bien humides.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 1.5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-018',
|
||||||
|
name: 'Paracoccus denitrificans',
|
||||||
|
type: 'bacterie-denitrifiante',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'pH neutre, teneur suffisante en nitrate et en carbone, zone légèrement aérée. Température: 10-45°C, Humidité: 60-98%. Zones moyennes (15-25 cm), bien mélangées.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.05,
|
||||||
|
max: 1,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-019',
|
||||||
|
name: 'Streptomyces spp.',
|
||||||
|
type: 'bacterie-filamenteuse-fongicide',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: true,
|
||||||
|
},
|
||||||
|
applicationConditions: 'En conditions aérobies en amont ou en bordure des digesteurs. Température: 15-45°C, Humidité: 50-90%. Zone supérieure à 15-20 cm, bien aérée.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== RÉGULATEURS SPÉCIFIQUES MÉTHANISATION TEMPÉRATURE AMBIANTE =====
|
||||||
|
{
|
||||||
|
id: 'reg-020',
|
||||||
|
name: 'Lactobacillus plantarum',
|
||||||
|
type: 'bacterie-fermentative-lactique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: -1.2,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: true,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Inoculation en début de cycle ; bien mélanger dans substrat humide. Température: 10-45°C, Humidité: 60-98%. 5 à 20 cm, dans les premières couches du substrat.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.2,
|
||||||
|
max: 3,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-021',
|
||||||
|
name: 'Corynebacterium glutamicum',
|
||||||
|
type: 'bacterie-productrice-acides-amines',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
carbonNitrogenRatio: 20,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Apport modéré dans substrats mixtes (protéines + sucres). Température: 20-45°C, Humidité: 50-90%. Zone moyenne (15-25 cm).',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 1.5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-022',
|
||||||
|
name: 'Mycobacterium smegmatis',
|
||||||
|
type: 'bacterie-degradant-lipides',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Apport fractionné, suivi de température et d\'agitation recommandé. Température: 20-50°C, Humidité: 65-98%. 10 à 30 cm, bien intégré à la masse grasse.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.05,
|
||||||
|
max: 1,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-023',
|
||||||
|
name: 'Bacillus megaterium',
|
||||||
|
type: 'bacterie-sporulante-multifonctionnelle',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Inoculation directe dans substrats organiques après brassage. Température: 15-55°C, Humidité: 60-95%. 10 à 25 cm dans la zone humide.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== TRAITEMENT ALGUES, LARVES, BACTÉRIES =====
|
||||||
|
{
|
||||||
|
id: 'reg-024',
|
||||||
|
name: 'Scenedesmus obliquus',
|
||||||
|
type: 'micro-algue-verte',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: true,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Nécessite lumière naturelle ou LED spectre bleu/rouge ; brassage lent. Température: 15-32°C, Humidité: >95%. Colonise la surface des bassins ou interfaces air/eau.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-025',
|
||||||
|
name: 'Nannochloropsis oculata',
|
||||||
|
type: 'micro-algue-marine',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Milieu salin ou saumâtre, apport régulier en lumière et CO₂. Température: 20-28°C, Humidité: >95%. Zone photique de 5-15 cm, en eau peu agitée.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-026',
|
||||||
|
name: 'Chlorella vulgaris',
|
||||||
|
type: 'micro-algue-verte',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Lumière directe ou artificielle, injection contrôlée de CO₂, agitation douce. Température: 18-30°C, Humidité: >95%. Surface lumineuse (0-15 cm).',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-027',
|
||||||
|
name: 'Pseudomonas fluorescens',
|
||||||
|
type: 'bacterie-bioremédiation',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Inoculation dans substrats humides et oxygénés ; éviter l\'anaérobie stricte. Température: 15-35°C, Humidité: 60-100%. Zone moyenne à superficielle (5-20 cm).',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-028',
|
||||||
|
name: 'Pseudomonas putida',
|
||||||
|
type: 'bacterie-versatile-metabolique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Nécessite un minimum d\'oxygène ; substrat humide, lumière non nécessaire. Température: 10-37°C, Humidité: 65-98%. Superficie et interfaces (eau/air ou litière humide).',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-029',
|
||||||
|
name: 'Gluconobacter oxydans',
|
||||||
|
type: 'bacterie-aerobique-acetique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Phase aérobie légère ou microaérophile, suivi du pH recommandé. Température: 20-35°C, Humidité: 60-98%. Zone superficielle à 10 cm max.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.05,
|
||||||
|
max: 1,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-030',
|
||||||
|
name: 'Rhodobacter sphaeroides',
|
||||||
|
type: 'bacterie-photosynthetique-anaerobie',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Lumière rouge ou naturelle indirecte, substrats riches en C organique. Température: 15-40°C, Humidité: >85%. Zone éclairée, souvent à l\'interface liquide/gaz.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 1.5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-031',
|
||||||
|
name: 'Rhodospirillum rubrum',
|
||||||
|
type: 'bacterie-photosynthetique-anoxygénique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Milieu semi-stagnant, lumière indirecte, supplément CO₂ ou H₂. Température: 15-38°C, Humidité: >85%. Interface liquide-gaz dans zones lumineuses.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 1.5,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== TRAITEMENT CHAMPIGNONS, VERS, BACTÉRIES =====
|
||||||
|
{
|
||||||
|
id: 'reg-032',
|
||||||
|
name: 'Ganoderma',
|
||||||
|
type: 'champignon-ligninolytique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Température basse, bonne aération, humidité contrôlée ; nécessite structure porteuse. Température: 5-28°C, Humidité: 60-90%. Zone superficielle ou colonisation de blocs lignocellulosiques.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-033',
|
||||||
|
name: 'Pleurotus spp.',
|
||||||
|
type: 'champignon-lignocellulosique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Apport de substrat fibreux, maintien d\'une humidité constante, éviter l\'immersion. Température: 8-28°C, Humidité: 65-90%. Surface à 20 cm en lit ou blocs mycélisés.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-034',
|
||||||
|
name: 'Penicillium chrysogenum',
|
||||||
|
type: 'moisissure-filamenteuse',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: true,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Aération légère, température fraîche, surface humide non saturée. Température: 5-25°C, Humidité: 60-90%. Zone superficielle, 5-10 cm sur substrats humides.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-035',
|
||||||
|
name: 'Aspergillus niger',
|
||||||
|
type: 'moisissure-filamenteuse',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Aération minimale, éviter saturation en eau, pH légèrement acide. Température: 10-40°C, Humidité: 50-85%. 5 à 15 cm ; préfère surfaces fibreuses ou compostées.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-036',
|
||||||
|
name: 'Eisenia fetida',
|
||||||
|
type: 'ver-composteur',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Litière végétale stable, humidité constante, éviter substrats toxiques ou trop gras. Température: 10-28°C, Humidité: 60-85%. 5 à 25 cm dans le lit de décomposition.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-037',
|
||||||
|
name: 'Lumbricus rubellus',
|
||||||
|
type: 'ver-de-terre-epige',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Substrat végétal non compacté, humidité constante, pas de forte lumière. Température: 5-25°C, Humidité: 65-90%. 0 à 15 cm ; préfère couches de surface.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.3,
|
||||||
|
max: 3,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== TRAITEMENT PLANTES, VERS, BACTÉRIES =====
|
||||||
|
{
|
||||||
|
id: 'reg-038',
|
||||||
|
name: 'Fougère (bioindicatrice / phytoextratrice)',
|
||||||
|
type: 'plante-hyperaccumulatrice-metaux',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: true,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Plantation ou bouturage dans substrat filtrant, arrosage goutte-à-goutte. Température: 10-30°C, Humidité: 50-85%. Sol ou substrat meuble de 15-30 cm.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 10,
|
||||||
|
max: 50,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-039',
|
||||||
|
name: 'Ray-grass (Lolium perenne)',
|
||||||
|
type: 'plante-fixatrice-azote',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
nitrogen: 2.0,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Semis direct sur couche filtrante, arrosage contrôlé. Température: 8-30°C, Humidité: 40-85%. 10 à 20 cm pour enracinement dense.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 5,
|
||||||
|
max: 30,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-040',
|
||||||
|
name: 'Trèfle blanc (Trifolium repens)',
|
||||||
|
type: 'legumineuse-fixatrice-azote',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
nitrogen: 3.0,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Semis ou bouture, apport initial de Rhizobium recommandé. Température: 5-28°C, Humidité: 50-90%. 10 à 15 cm dans sol aéré.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 5,
|
||||||
|
max: 25,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-041',
|
||||||
|
name: 'Moutarde indienne (Brassica juncea)',
|
||||||
|
type: 'plante-phytoextractrice',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: true,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Semis en surface ou substrat minéral, récolte après 30-40 jours. Température: 10-32°C, Humidité: 40-85%. 15 à 25 cm, sol bien drainé.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 5,
|
||||||
|
max: 30,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-042',
|
||||||
|
name: 'Glomus spp.',
|
||||||
|
type: 'champignon-mycorhizien',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phosphorus: 0.5,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Co-inoculation avec plantes herbacées, ne pas stériliser le substrat. Température: 12-30°C, Humidité: 60-95%. Racines profondes (15-30 cm) en contact symbiotique.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-043',
|
||||||
|
name: 'Nitrosomonas europaea',
|
||||||
|
type: 'bacterie-nitrifiante',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Apport d\'oxygène, éviter pH < 6,5, température stabilisée. Température: 10-35°C, Humidité: 70-100%. Couche superficielle (5-15 cm), bonne aération.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.05,
|
||||||
|
max: 1,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-044',
|
||||||
|
name: 'Nitrobacter winogradskyi',
|
||||||
|
type: 'bacterie-nitrifiante',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Oxygénation douce, neutralité du pH, interaction avec N. europaea. Température: 10-35°C, Humidité: 70-100%. 5-15 cm ; préfère substrats légers et bien drainés.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.05,
|
||||||
|
max: 1,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== MÉTHANISATION THERMOPHILE (55°C) =====
|
||||||
|
{
|
||||||
|
id: 'reg-045',
|
||||||
|
name: 'Clostridium spp. (haut rendement CH₄)',
|
||||||
|
type: 'bacterie-anaerobie-thermophile',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Anaérobie strict, brassage lent, injection initiale en substrat chaud (> 45°C). Température: 40-65°C, Humidité: 70-95%. 20 à 30 cm dans la zone anaérobie active.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.2,
|
||||||
|
max: 3,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-046',
|
||||||
|
name: 'Bacillus subtilis (résistance thermique)',
|
||||||
|
type: 'bacterie-sporulante-thermophile',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: true,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Injection fractionnée ou co-culture dans substrat riche, activation par température. Température: 30-65°C, Humidité: 55-90%. 10 à 30 cm dans substrat en digestion.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-047',
|
||||||
|
name: 'Lactobacillus spp. (thermophile)',
|
||||||
|
type: 'bacterie-fermentative-lactique-thermophile',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: -1.0,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: true,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Apport initial ou en renfort après agitation, éviter pH > 7,5. Température: 30-55°C, Humidité: 60-98%. 10 à 20 cm, dans la phase humide du substrat.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.2,
|
||||||
|
max: 3,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-048',
|
||||||
|
name: 'Myxococcus xanthus',
|
||||||
|
type: 'bacterie-structurante-biofilm',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Milieu homogène avec substrats fibreux, faible brassage. Température: 25-50°C, Humidité: 60-90%. 10 à 25 cm, milieu semi-liquide ou fibreux.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.05,
|
||||||
|
max: 1,
|
||||||
|
unit: 'L/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-049',
|
||||||
|
name: 'H₂ (réacteur Sabatier)',
|
||||||
|
type: 'gaz-reducteur-catalytique',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Injection lente dans réacteur Sabatier ou en tête de digesteur ; catalyseur requis. Température: 40-60°C. Atmosphère gazeuse du digesteur (non immergé).',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-050',
|
||||||
|
name: 'CO₂ (tampon thermochimique)',
|
||||||
|
type: 'gaz-carboné-tampon',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
phAdjustment: -0.3,
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Diffusion douce, sans surpression. Température: 5-65°C. Phase gazeuse du digesteur ou zone de dissolution.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-051',
|
||||||
|
name: 'Biochar (stabilisation, filtration gaz)',
|
||||||
|
type: 'charbon-vegetal-microporeux',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: true,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Sec ou légèrement humide, bien réparti. Température: 0-70°C, Humidité: 10-70%. 15 à 30 cm ; intégré ou dispersé dans le digesteur.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reg-052',
|
||||||
|
name: 'Digesta (inoculum thermophile)',
|
||||||
|
type: 'residu-microbien-thermophile',
|
||||||
|
regulatoryCharacteristics: {
|
||||||
|
metalBinding: false,
|
||||||
|
pathogenReduction: false,
|
||||||
|
},
|
||||||
|
applicationConditions: 'Utilisation rapide après extraction, suivi température et pH. Température: 30-65°C, Humidité: 75-98%. 10 à 25 cm, en mélange homogène avec substrat frais.',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 5,
|
||||||
|
max: 20,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]
|
||||||
114
src/data/seedWastes.ts
Normal file
114
src/data/seedWastes.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { Waste } from '@/types'
|
||||||
|
|
||||||
|
export const seedWastes: Waste[] = [
|
||||||
|
{
|
||||||
|
id: 'waste-001',
|
||||||
|
name: 'Bouses de vache',
|
||||||
|
originType: 'animals',
|
||||||
|
originSubType: 'vaches',
|
||||||
|
originUnitsPer1000m3Methane: 25,
|
||||||
|
bmp: 0.16, // Nm³ CH₄/kg VS
|
||||||
|
waterPercentage: 79,
|
||||||
|
regulationNeeds: [],
|
||||||
|
maxStorageDuration: 30,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waste-002',
|
||||||
|
name: 'Déchets verts broyés',
|
||||||
|
originType: 'other',
|
||||||
|
originSubType: 'ha espace verts',
|
||||||
|
originUnitsPer1000m3Methane: 52.6,
|
||||||
|
bmp: 0.16,
|
||||||
|
waterPercentage: 50,
|
||||||
|
regulationNeeds: [],
|
||||||
|
maxStorageDuration: 30,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waste-003',
|
||||||
|
name: 'Déchets de STEP - Boues',
|
||||||
|
originType: 'other',
|
||||||
|
originSubType: 'équivalent habitant',
|
||||||
|
originUnitsPer1000m3Methane: 1000,
|
||||||
|
bmp: 0.16,
|
||||||
|
waterPercentage: 78,
|
||||||
|
regulationNeeds: ['pathogenElimination', 'odorElimination'],
|
||||||
|
maxStorageDuration: 21,
|
||||||
|
notes: 'STEP sludge cannot be mixed with other wastes',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waste-004',
|
||||||
|
name: 'Déchets de STEP - Graisses',
|
||||||
|
originType: 'other',
|
||||||
|
originSubType: 'équivalent habitant',
|
||||||
|
originUnitsPer1000m3Methane: 35000,
|
||||||
|
bmp: 5.60,
|
||||||
|
waterPercentage: 45,
|
||||||
|
regulationNeeds: ['oilEmulsification', 'pathogenElimination'],
|
||||||
|
maxStorageDuration: 30,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waste-005',
|
||||||
|
name: 'Déchets de STEP - Sables',
|
||||||
|
originType: 'other',
|
||||||
|
originSubType: 'équivalent habitant',
|
||||||
|
originUnitsPer1000m3Methane: 2500,
|
||||||
|
bmp: 0.00,
|
||||||
|
waterPercentage: 22,
|
||||||
|
regulationNeeds: [],
|
||||||
|
maxStorageDuration: 60,
|
||||||
|
notes: 'No methane production (BMP = 0)',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waste-006',
|
||||||
|
name: 'Déchets de marchés alimentaires et restauration',
|
||||||
|
originType: 'markets',
|
||||||
|
originSubType: 'marchés alimentaires',
|
||||||
|
originUnitsPer1000m3Methane: 666.7,
|
||||||
|
bmp: 0.80,
|
||||||
|
waterPercentage: 70,
|
||||||
|
regulationNeeds: ['pathogenElimination', 'odorElimination'],
|
||||||
|
maxStorageDuration: 7,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waste-007',
|
||||||
|
name: 'Déchets sucrés',
|
||||||
|
originType: 'other',
|
||||||
|
originSubType: 'équivalent habitant',
|
||||||
|
originUnitsPer1000m3Methane: 20000,
|
||||||
|
bmp: 1.00,
|
||||||
|
waterPercentage: 20,
|
||||||
|
regulationNeeds: [],
|
||||||
|
maxStorageDuration: 30,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waste-008',
|
||||||
|
name: 'Huiles usagées',
|
||||||
|
originType: 'other',
|
||||||
|
originSubType: 'équivalent habitant',
|
||||||
|
originUnitsPer1000m3Methane: 50000,
|
||||||
|
bmp: 1.90,
|
||||||
|
waterPercentage: 2,
|
||||||
|
regulationNeeds: ['oilEmulsification'],
|
||||||
|
maxStorageDuration: 90,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'waste-009',
|
||||||
|
name: 'Algues invasives',
|
||||||
|
originType: 'other',
|
||||||
|
originSubType: 'km de plage infestée',
|
||||||
|
originUnitsPer1000m3Methane: 5,
|
||||||
|
bmp: 0.50,
|
||||||
|
waterPercentage: 70,
|
||||||
|
regulationNeeds: ['pathogenElimination', 'saltReduction'],
|
||||||
|
maxStorageDuration: 14,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]
|
||||||
40
src/hooks/useAuth.ts
Normal file
40
src/hooks/useAuth.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getUserSession, saveUserSession, clearUserSession } from '@/utils/storage'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [username, setUsername] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionUser = getUserSession()
|
||||||
|
if (sessionUser) {
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setUsername(sessionUser)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = (user: string, password: string): boolean => {
|
||||||
|
// Simple authentication for localhost
|
||||||
|
// Accepts any non-empty username/password combination
|
||||||
|
if (user && user.trim() && password && password.trim()) {
|
||||||
|
saveUserSession(user.trim())
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setUsername(user.trim())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
clearUserSession()
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
setUsername(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated,
|
||||||
|
username,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/hooks/useStorage.ts
Normal file
74
src/hooks/useStorage.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { StorageData, BaseEntity } from '@/types'
|
||||||
|
import { loadStorage, saveStorage } from '@/utils/storage'
|
||||||
|
|
||||||
|
export function useStorage() {
|
||||||
|
const [data, setData] = useState<StorageData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadedData = loadStorage()
|
||||||
|
setData(loadedData)
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateData = useCallback((updater: (data: StorageData) => StorageData) => {
|
||||||
|
setData((currentData) => {
|
||||||
|
if (!currentData) return currentData
|
||||||
|
const updated = updater(currentData)
|
||||||
|
saveStorage(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addEntity = useCallback(<T extends BaseEntity>(
|
||||||
|
collection: keyof StorageData,
|
||||||
|
entity: T
|
||||||
|
) => {
|
||||||
|
updateData((data) => {
|
||||||
|
const collectionData = data[collection] as T[]
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
[collection]: [...collectionData, entity],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [updateData])
|
||||||
|
|
||||||
|
const updateEntity = useCallback(<T extends BaseEntity>(
|
||||||
|
collection: keyof StorageData,
|
||||||
|
id: string,
|
||||||
|
updates: Partial<T>
|
||||||
|
) => {
|
||||||
|
updateData((data) => {
|
||||||
|
const collectionData = data[collection] as T[]
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
[collection]: collectionData.map((item) =>
|
||||||
|
item.id === id ? { ...item, ...updates, updatedAt: new Date().toISOString() } : item
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [updateData])
|
||||||
|
|
||||||
|
const deleteEntity = useCallback((
|
||||||
|
collection: keyof StorageData,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
updateData((data) => {
|
||||||
|
const collectionData = data[collection] as BaseEntity[]
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
[collection]: collectionData.filter((item) => item.id !== id),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [updateData])
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
updateData,
|
||||||
|
addEntity,
|
||||||
|
updateEntity,
|
||||||
|
deleteEntity,
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/index.css
Normal file
90
src/index.css
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Primary Colors */
|
||||||
|
--primary-green: #2D7A4F;
|
||||||
|
--primary-green-light: #4A9D6E;
|
||||||
|
--primary-green-dark: #1F5A3A;
|
||||||
|
--primary-green-accent: #6BC48A;
|
||||||
|
|
||||||
|
/* Secondary Colors */
|
||||||
|
--secondary-blue: #2563EB;
|
||||||
|
--secondary-blue-light: #3B82F6;
|
||||||
|
--secondary-blue-dark: #1E40AF;
|
||||||
|
|
||||||
|
/* Neutral Colors (Dark Mode) */
|
||||||
|
--background: #1A1A1A;
|
||||||
|
--background-secondary: #2D2D2D;
|
||||||
|
--background-tertiary: #404040;
|
||||||
|
--text-primary: #FFFFFF;
|
||||||
|
--text-secondary: #B0B0B0;
|
||||||
|
--text-tertiary: #808080;
|
||||||
|
--border: #404040;
|
||||||
|
--border-focus: #4A9D6E;
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--success: #10B981;
|
||||||
|
--warning: #F59E0B;
|
||||||
|
--error: #EF4444;
|
||||||
|
--info: #3B82F6;
|
||||||
|
--pending: #F59E0B;
|
||||||
|
--completed: #10B981;
|
||||||
|
--to-do: #6C757D;
|
||||||
|
|
||||||
|
/* Status Workflow Colors */
|
||||||
|
--to-be-approached: #F59E0B;
|
||||||
|
--loi-ok: #3B82F6;
|
||||||
|
--in-progress: #8B5CF6;
|
||||||
|
--completed: #10B981;
|
||||||
|
--na: #9CA3AF;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 16px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
--spacing-2xl: 48px;
|
||||||
|
--spacing-3xl: 64px;
|
||||||
|
--spacing-4xl: 96px;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--background-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
27
src/main.tsx
Normal file
27
src/main.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
// Check if running on localhost
|
||||||
|
if (window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: '#EF4444' }}>
|
||||||
|
<h1>Access Restricted</h1>
|
||||||
|
<p>This application can only be accessed from localhost.</p>
|
||||||
|
<p>Current hostname: {window.location.hostname}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
src/pages/BusinessPlanPage.css
Normal file
204
src/pages/BusinessPlanPage.css
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
.business-plan-page {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-card {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-plan-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-card {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.valorizations-card {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valorizations-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.valorization-item {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.valorization-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.valorization-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-green);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.economic-card {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.financial-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.financial-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.financial-table th {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.financial-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.financial-table tr:hover {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-cell {
|
||||||
|
color: var(--primary-green);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-section {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-section-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-display {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-content {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenues-card,
|
||||||
|
.costs-card {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenues-grid,
|
||||||
|
.costs-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-item,
|
||||||
|
.cost-item {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-label,
|
||||||
|
.cost-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-years,
|
||||||
|
.cost-years {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
463
src/pages/BusinessPlanPage.tsx
Normal file
463
src/pages/BusinessPlanPage.tsx
Normal file
@ -0,0 +1,463 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { Project, BusinessPlan } from '@/types'
|
||||||
|
import { calculateYields } from '@/utils/calculations/yields'
|
||||||
|
import { VALORIZATION_PARAMS, BITCOIN_CONFIG, MODULE_CONFIG } from '@/utils/constants'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Select from '@/components/base/Select'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import { formatCurrency, formatNumber, formatDate } from '@/utils/formatters'
|
||||||
|
import './BusinessPlanPage.css'
|
||||||
|
|
||||||
|
export default function BusinessPlanPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const projectIdFromUrl = searchParams.get('project')
|
||||||
|
const { data, updateEntity } = useStorage()
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>(projectIdFromUrl || '')
|
||||||
|
|
||||||
|
const projects = data?.projects || []
|
||||||
|
const wastes = data?.wastes || []
|
||||||
|
const treatmentSites = data?.treatmentSites || []
|
||||||
|
const services = data?.services || []
|
||||||
|
|
||||||
|
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||||
|
const selectedWaste = selectedProject?.wasteCharacteristicsOverride?.wasteId
|
||||||
|
? wastes.find((w) => w.id === selectedProject.wasteCharacteristicsOverride?.wasteId)
|
||||||
|
: undefined
|
||||||
|
const selectedTreatmentSite = selectedProject
|
||||||
|
? treatmentSites.find((s) => s.id === selectedProject.treatmentSiteId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const yields = selectedProject && selectedWaste && selectedTreatmentSite
|
||||||
|
? calculateYields(selectedProject, selectedWaste, selectedTreatmentSite)
|
||||||
|
: null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectIdFromUrl && !selectedProjectId) {
|
||||||
|
setSelectedProjectId(projectIdFromUrl)
|
||||||
|
}
|
||||||
|
}, [projectIdFromUrl])
|
||||||
|
|
||||||
|
const projectOptions = projects.map((project) => ({
|
||||||
|
value: project.id,
|
||||||
|
label: project.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const initializeBusinessPlan = (): BusinessPlan => {
|
||||||
|
const years = Array(10).fill(0)
|
||||||
|
return {
|
||||||
|
revenues: {
|
||||||
|
rawRental: years,
|
||||||
|
biologicalTreatment: years,
|
||||||
|
bitcoinManagement: years,
|
||||||
|
fertilizers: years,
|
||||||
|
wasteHeat: years,
|
||||||
|
carbonCredits: years,
|
||||||
|
brownfield: years,
|
||||||
|
transport: years,
|
||||||
|
commercialPartnerships: years,
|
||||||
|
other: years,
|
||||||
|
},
|
||||||
|
variableCosts: {
|
||||||
|
rentalServices: years,
|
||||||
|
commissions: years,
|
||||||
|
otherVariable: years,
|
||||||
|
transport: years,
|
||||||
|
},
|
||||||
|
fixedCosts: {
|
||||||
|
salaries: years,
|
||||||
|
marketing: years,
|
||||||
|
rd: years,
|
||||||
|
administrative: years,
|
||||||
|
otherGeneral: years,
|
||||||
|
},
|
||||||
|
investments: {
|
||||||
|
equipment: years,
|
||||||
|
technology: years,
|
||||||
|
patents: years,
|
||||||
|
},
|
||||||
|
useOfFunds: {
|
||||||
|
productDevelopment: years,
|
||||||
|
marketing: years,
|
||||||
|
team: years,
|
||||||
|
structure: years,
|
||||||
|
},
|
||||||
|
kpis: {
|
||||||
|
activeUsers: years,
|
||||||
|
cac: years,
|
||||||
|
ltv: years,
|
||||||
|
breakEvenDays: years,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessPlan = selectedProject?.businessPlan || initializeBusinessPlan()
|
||||||
|
|
||||||
|
const updateBusinessPlanValue = (
|
||||||
|
category: keyof BusinessPlan,
|
||||||
|
subCategory: string,
|
||||||
|
yearIndex: number,
|
||||||
|
value: number
|
||||||
|
) => {
|
||||||
|
if (!selectedProject) return
|
||||||
|
|
||||||
|
const updated = { ...businessPlan }
|
||||||
|
const categoryData = updated[category] as any
|
||||||
|
categoryData[subCategory] = [...categoryData[subCategory]]
|
||||||
|
categoryData[subCategory][yearIndex] = value
|
||||||
|
|
||||||
|
updateEntity('projects', selectedProject.id, {
|
||||||
|
...selectedProject,
|
||||||
|
businessPlan: updated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTotalRevenues = (yearIndex: number): number => {
|
||||||
|
return (
|
||||||
|
businessPlan.revenues.rawRental[yearIndex] +
|
||||||
|
businessPlan.revenues.biologicalTreatment[yearIndex] +
|
||||||
|
businessPlan.revenues.bitcoinManagement[yearIndex] +
|
||||||
|
businessPlan.revenues.fertilizers[yearIndex] +
|
||||||
|
businessPlan.revenues.wasteHeat[yearIndex] +
|
||||||
|
businessPlan.revenues.carbonCredits[yearIndex] +
|
||||||
|
businessPlan.revenues.brownfield[yearIndex] +
|
||||||
|
businessPlan.revenues.transport[yearIndex] +
|
||||||
|
businessPlan.revenues.commercialPartnerships[yearIndex] +
|
||||||
|
businessPlan.revenues.other[yearIndex]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTotalVariableCosts = (yearIndex: number): number => {
|
||||||
|
return (
|
||||||
|
businessPlan.variableCosts.rentalServices[yearIndex] +
|
||||||
|
businessPlan.variableCosts.commissions[yearIndex] +
|
||||||
|
businessPlan.variableCosts.otherVariable[yearIndex] +
|
||||||
|
businessPlan.variableCosts.transport[yearIndex]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateGrossMargin = (yearIndex: number): number => {
|
||||||
|
return calculateTotalRevenues(yearIndex) - calculateTotalVariableCosts(yearIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTotalFixedCosts = (yearIndex: number): number => {
|
||||||
|
return (
|
||||||
|
businessPlan.fixedCosts.salaries[yearIndex] +
|
||||||
|
businessPlan.fixedCosts.marketing[yearIndex] +
|
||||||
|
businessPlan.fixedCosts.rd[yearIndex] +
|
||||||
|
businessPlan.fixedCosts.administrative[yearIndex] +
|
||||||
|
businessPlan.fixedCosts.otherGeneral[yearIndex]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateEBITDA = (yearIndex: number): number => {
|
||||||
|
return calculateGrossMargin(yearIndex) - calculateTotalFixedCosts(yearIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTotalInvestments = (yearIndex: number): number => {
|
||||||
|
return (
|
||||||
|
businessPlan.investments.equipment[yearIndex] +
|
||||||
|
businessPlan.investments.technology[yearIndex] +
|
||||||
|
businessPlan.investments.patents[yearIndex]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateValorizations = () => {
|
||||||
|
if (!yields || !selectedProject) return null
|
||||||
|
|
||||||
|
const daysPerYear = 365
|
||||||
|
const wasteQuantityPerYear = (MODULE_CONFIG.CAPACITY_PER_MODULE * selectedProject.numberOfModules * daysPerYear) / 1000 // kT/year
|
||||||
|
|
||||||
|
return {
|
||||||
|
wasteTreatment: wasteQuantityPerYear * VALORIZATION_PARAMS.WASTE_TREATMENT_PER_TONNE,
|
||||||
|
fertilizer: (yields.fertilizer * daysPerYear) * VALORIZATION_PARAMS.FERTILIZER_PER_TONNE,
|
||||||
|
heat: (yields.heatEnergyKWh * daysPerYear) * VALORIZATION_PARAMS.HEAT_PER_TONNE,
|
||||||
|
ch4Carbon: (yields.methane * daysPerYear * 0.717 / 1000) * VALORIZATION_PARAMS.CARBON_CH4_BURNED_PER_TCO2E, // Convert m³ to t, then to tCO₂e
|
||||||
|
co2Carbon: (yields.co2 * daysPerYear * 1.98 / 1000) * VALORIZATION_PARAMS.CARBON_CO2_SEQUESTERED_PER_TCO2E, // Convert m³ to t, then to tCO₂e
|
||||||
|
energyCarbon: (yields.netElectricalPower * daysPerYear * 24) * VALORIZATION_PARAMS.CARBON_ELECTRICITY_AVOIDED_PER_KW,
|
||||||
|
bitcoin: yields.bitcoinsPerYear * BITCOIN_CONFIG.BITCOIN_PRICE_EUR,
|
||||||
|
land: VALORIZATION_PARAMS.BROWNFIELD_AREA * VALORIZATION_PARAMS.BROWNFIELD_VALORIZATION_RATE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valorizations = calculateValorizations()
|
||||||
|
|
||||||
|
const FormulaDisplay = ({ formula, description }: { formula: string; description: string }) => (
|
||||||
|
<div className="formula-display">
|
||||||
|
<div className="formula-label">{description}</div>
|
||||||
|
<div className="formula-content">{formula}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="business-plan-page">
|
||||||
|
<h1 className="page-title">Business Plan</h1>
|
||||||
|
|
||||||
|
<Card className="selector-card">
|
||||||
|
<Select
|
||||||
|
label="Select Project"
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
options={projectOptions.length > 0 ? projectOptions : [{ value: '', label: 'No projects available' }]}
|
||||||
|
helpText="Select a project to view/edit its business plan"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!selectedProject && (
|
||||||
|
<Card>
|
||||||
|
<p className="empty-message">Please select a project to view business plan</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedProject && (
|
||||||
|
<div className="business-plan-content">
|
||||||
|
<Card title="Project Header" className="header-card">
|
||||||
|
<div className="header-info">
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">Project:</span>
|
||||||
|
<span className="info-value">{selectedProject.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">Duration:</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{formatDate(selectedProject.startDate)} - {formatDate(selectedProject.endDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">Treatment Site:</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{treatmentSites.find((s) => s.id === selectedProject.treatmentSiteId)?.name || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">Collection Sites:</span>
|
||||||
|
<span className="info-value">{selectedProject.collectionSiteIds.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">Number of Modules:</span>
|
||||||
|
<span className="info-value">{selectedProject.numberOfModules}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{valorizations && (
|
||||||
|
<Card title="Pricing Characteristics (Valorizations per Year)" className="valorizations-card">
|
||||||
|
<div className="valorizations-grid">
|
||||||
|
<div className="valorization-item">
|
||||||
|
<div className="valorization-label">Waste Treatment</div>
|
||||||
|
<div className="valorization-value">{formatCurrency(valorizations.wasteTreatment)}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Waste_treatment = Waste_quantity (t/year) × 100 €/t"
|
||||||
|
description="Waste treatment valorization"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="valorization-item">
|
||||||
|
<div className="valorization-label">Fertilizer</div>
|
||||||
|
<div className="valorization-value">{formatCurrency(valorizations.fertilizer)}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Fertilizer = Fertilizer_quantity (t/year) × 215 €/t"
|
||||||
|
description="Fertilizer valorization"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="valorization-item">
|
||||||
|
<div className="valorization-label">Heat</div>
|
||||||
|
<div className="valorization-value">{formatCurrency(valorizations.heat)}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Heat = Heat_quantity (t/year) × 0.12 €/t"
|
||||||
|
description="Heat valorization"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="valorization-item">
|
||||||
|
<div className="valorization-label">CH₄ Carbon Equivalent</div>
|
||||||
|
<div className="valorization-value">{formatCurrency(valorizations.ch4Carbon)}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="CH4_carbon = CH4_quantity (tCO₂e/year) × 172 €/tCO₂e"
|
||||||
|
description="Carbon equivalent valorization - burned methane"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="valorization-item">
|
||||||
|
<div className="valorization-label">CO₂ Carbon Equivalent</div>
|
||||||
|
<div className="valorization-value">{formatCurrency(valorizations.co2Carbon)}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="CO2_carbon = CO2_sequestered (tCO₂e/year) × 27 €/tCO₂e"
|
||||||
|
description="Carbon equivalent valorization - sequestered CO₂"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="valorization-item">
|
||||||
|
<div className="valorization-label">Energy Carbon Equivalent</div>
|
||||||
|
<div className="valorization-value">{formatCurrency(valorizations.energyCarbon)}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Energy_carbon = Electricity_avoided (kW/year) × 0.12 €/kW"
|
||||||
|
description="Carbon equivalent valorization - avoided electricity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="valorization-item">
|
||||||
|
<div className="valorization-label">Bitcoin Value</div>
|
||||||
|
<div className="valorization-value">{formatCurrency(valorizations.bitcoin)}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Bitcoin_value = Bitcoin_quantity (BTC) × 100,000 €/BTC"
|
||||||
|
description="Bitcoin valorization"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="valorization-item">
|
||||||
|
<div className="valorization-label">Land Valorization</div>
|
||||||
|
<div className="valorization-value">{formatCurrency(valorizations.land)}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Land = Brownfield_area (m²) × Valorization_rate (€/m²)"
|
||||||
|
description="Land valorization"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title="Economic Characteristics (10 Years)" className="economic-card">
|
||||||
|
<div className="financial-table-container">
|
||||||
|
<table className="financial-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Year</th>
|
||||||
|
<th>Total Revenues</th>
|
||||||
|
<th>Total Variable Costs</th>
|
||||||
|
<th>Gross Margin</th>
|
||||||
|
<th>Total Fixed Costs</th>
|
||||||
|
<th>EBITDA</th>
|
||||||
|
<th>Total Investments</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((yearIndex) => {
|
||||||
|
const year = yearIndex + 1
|
||||||
|
const totalRevenues = calculateTotalRevenues(yearIndex)
|
||||||
|
const totalVariableCosts = calculateTotalVariableCosts(yearIndex)
|
||||||
|
const grossMargin = calculateGrossMargin(yearIndex)
|
||||||
|
const totalFixedCosts = calculateTotalFixedCosts(yearIndex)
|
||||||
|
const ebitda = calculateEBITDA(yearIndex)
|
||||||
|
const totalInvestments = calculateTotalInvestments(yearIndex)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={yearIndex}>
|
||||||
|
<td className="year-cell">Year {year}</td>
|
||||||
|
<td>{formatCurrency(totalRevenues)}</td>
|
||||||
|
<td>{formatCurrency(totalVariableCosts)}</td>
|
||||||
|
<td className="highlight-cell">{formatCurrency(grossMargin)}</td>
|
||||||
|
<td>{formatCurrency(totalFixedCosts)}</td>
|
||||||
|
<td className="highlight-cell">{formatCurrency(ebitda)}</td>
|
||||||
|
<td>{formatCurrency(totalInvestments)}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="formula-section">
|
||||||
|
<h3 className="formula-section-title">Calculation Formulas</h3>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Total_Revenues = Sum of all revenue items"
|
||||||
|
description="Total revenues calculation"
|
||||||
|
/>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Total_Variable_Costs = Sum of all variable cost items"
|
||||||
|
description="Total variable costs calculation"
|
||||||
|
/>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Gross_Margin = Total_Revenues - Total_Variable_Costs"
|
||||||
|
description="Gross margin calculation"
|
||||||
|
/>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Total_Fixed_Costs = Sum of all fixed cost items"
|
||||||
|
description="Total fixed costs calculation"
|
||||||
|
/>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="EBITDA = Gross_Margin - Total_Fixed_Costs"
|
||||||
|
description="Operating result (EBITDA) calculation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Revenues Configuration" className="revenues-card">
|
||||||
|
<p className="section-description">
|
||||||
|
Configure revenues for each year. Values are in € per module per year.
|
||||||
|
</p>
|
||||||
|
<div className="revenues-grid">
|
||||||
|
{Object.entries(businessPlan.revenues).map(([key, values]) => (
|
||||||
|
<div key={key} className="revenue-item">
|
||||||
|
<label className="revenue-label">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||||
|
<div className="revenue-years">
|
||||||
|
{values.map((value, yearIndex) => (
|
||||||
|
<Input
|
||||||
|
key={yearIndex}
|
||||||
|
label={`Year ${yearIndex + 1}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBusinessPlanValue('revenues', key, yearIndex, parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Variable Costs Configuration" className="costs-card">
|
||||||
|
<div className="costs-grid">
|
||||||
|
{Object.entries(businessPlan.variableCosts).map(([key, values]) => (
|
||||||
|
<div key={key} className="cost-item">
|
||||||
|
<label className="cost-label">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||||
|
<div className="cost-years">
|
||||||
|
{values.map((value, yearIndex) => (
|
||||||
|
<Input
|
||||||
|
key={yearIndex}
|
||||||
|
label={`Year ${yearIndex + 1}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBusinessPlanValue('variableCosts', key, yearIndex, parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Fixed Costs Configuration" className="costs-card">
|
||||||
|
<div className="costs-grid">
|
||||||
|
{Object.entries(businessPlan.fixedCosts).map(([key, values]) => (
|
||||||
|
<div key={key} className="cost-item">
|
||||||
|
<label className="cost-label">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||||
|
<div className="cost-years">
|
||||||
|
{values.map((value, yearIndex) => (
|
||||||
|
<Input
|
||||||
|
key={yearIndex}
|
||||||
|
label={`Year ${yearIndex + 1}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBusinessPlanValue('fixedCosts', key, yearIndex, parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
src/pages/DashboardPage.css
Normal file
117
src/pages/DashboardPage.css
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
.dashboard {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-4xl);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-card {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-green);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section {
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-empty {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--spacing-2xl);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-projects {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-project-card {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
transition: border-color 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-project-card:hover {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-project-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-project-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-action-button {
|
||||||
|
background-color: var(--primary-green);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease-in-out;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-action-button:hover {
|
||||||
|
background-color: var(--primary-green-light);
|
||||||
|
}
|
||||||
80
src/pages/DashboardPage.tsx
Normal file
80
src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import './DashboardPage.css'
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data, loading } = useStorage()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="dashboard-loading">Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = data?.projects || []
|
||||||
|
const wastes = data?.wastes || []
|
||||||
|
const services = data?.services || []
|
||||||
|
const treatmentSites = data?.treatmentSites || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<h1 className="dashboard-title">Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="dashboard-metrics">
|
||||||
|
<div className="dashboard-metric-card">
|
||||||
|
<div className="dashboard-metric-value">{projects.length}</div>
|
||||||
|
<div className="dashboard-metric-label">Active Projects</div>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-metric-card">
|
||||||
|
<div className="dashboard-metric-value">
|
||||||
|
{projects.reduce((sum, p) => sum + p.numberOfModules, 0)}
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-metric-label">Total Modules</div>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-metric-card">
|
||||||
|
<div className="dashboard-metric-value">{wastes.length}</div>
|
||||||
|
<div className="dashboard-metric-label">Waste Types</div>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-metric-card">
|
||||||
|
<div className="dashboard-metric-value">{treatmentSites.length}</div>
|
||||||
|
<div className="dashboard-metric-label">Treatment Sites</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-section">
|
||||||
|
<h2 className="dashboard-section-title">Recent Projects</h2>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="dashboard-empty">
|
||||||
|
<p>No projects yet. Create your first project to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dashboard-projects">
|
||||||
|
{projects.slice(0, 5).map((project) => (
|
||||||
|
<div key={project.id} className="dashboard-project-card">
|
||||||
|
<h3 className="dashboard-project-name">{project.name}</h3>
|
||||||
|
<p className="dashboard-project-info">
|
||||||
|
{project.numberOfModules} modules • {treatmentSites.find(s => s.id === project.treatmentSiteId)?.name || 'Unknown site'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-section">
|
||||||
|
<h2 className="dashboard-section-title">Quick Actions</h2>
|
||||||
|
<div className="dashboard-actions">
|
||||||
|
<a href="/projects/new" className="dashboard-action-button">
|
||||||
|
Create New Project
|
||||||
|
</a>
|
||||||
|
<a href="/configuration/waste" className="dashboard-action-button">
|
||||||
|
Configure Waste Types
|
||||||
|
</a>
|
||||||
|
<a href="/yields" className="dashboard-action-button">
|
||||||
|
View Yields
|
||||||
|
</a>
|
||||||
|
<a href="/business-plan" className="dashboard-action-button">
|
||||||
|
Business Plan
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/pages/HelpPage.css
Normal file
57
src/pages/HelpPage.css
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
.help-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section ol,
|
||||||
|
.help-section ul {
|
||||||
|
margin-left: var(--spacing-xl);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section li {
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section p {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background-color: var(--background);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--primary-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: var(--warning);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
56
src/pages/HelpPage.tsx
Normal file
56
src/pages/HelpPage.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import './HelpPage.css'
|
||||||
|
|
||||||
|
export default function HelpPage() {
|
||||||
|
return (
|
||||||
|
<div className="help-page">
|
||||||
|
<h1 className="page-title">Help & Documentation</h1>
|
||||||
|
|
||||||
|
<div className="help-content">
|
||||||
|
<Card title="User Guide" className="help-section">
|
||||||
|
<h3>Getting Started</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Configure waste types in Configuration → Waste</li>
|
||||||
|
<li>Configure natural regulators in Configuration → Regulators</li>
|
||||||
|
<li>Configure services pricing in Configuration → Services</li>
|
||||||
|
<li>Create treatment sites in Projects → Treatment Sites</li>
|
||||||
|
<li>Create waste sites in Projects → Waste Sites</li>
|
||||||
|
<li>Create a project in Projects → Projects</li>
|
||||||
|
<li>View yields in Yields page</li>
|
||||||
|
<li>Configure business plan in Business Plan page</li>
|
||||||
|
</ol>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Formula Reference" className="help-section">
|
||||||
|
<p>
|
||||||
|
All calculation formulas are displayed on the Yields and Business Plan pages.
|
||||||
|
Formulas use monospace font and show the complete calculation method.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For detailed formula documentation, see <code>formulas_reference.md</code>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Data Management" className="help-section">
|
||||||
|
<h3>Export Data</h3>
|
||||||
|
<p>Go to Settings → Export Data to download all your data as JSON.</p>
|
||||||
|
|
||||||
|
<h3>Import Data</h3>
|
||||||
|
<p>Go to Settings → Import Data to replace all data with imported JSON file.</p>
|
||||||
|
<p className="warning">Warning: Importing will replace all existing data!</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="FAQ" className="help-section">
|
||||||
|
<h3>How do I calculate yields?</h3>
|
||||||
|
<p>Yields are automatically calculated when you select a project. Make sure the project has a configured waste type and treatment site.</p>
|
||||||
|
|
||||||
|
<h3>How do I configure service pricing?</h3>
|
||||||
|
<p>Go to Configuration → Services and set pricing for each year (1-10). Pricing is per module per year.</p>
|
||||||
|
|
||||||
|
<h3>Can I override waste characteristics per project?</h3>
|
||||||
|
<p>Yes, in the Project Configuration page, you can override BMP and water percentage for that specific project.</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
src/pages/LoginPage.css
Normal file
107
src/pages/LoginPage.css
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--background);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input {
|
||||||
|
height: 44px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
height: 44px;
|
||||||
|
background-color: var(--primary-green);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover {
|
||||||
|
background-color: var(--primary-green-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:active {
|
||||||
|
background-color: var(--primary-green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-note {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
85
src/pages/LoginPage.tsx
Normal file
85
src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import './LoginPage.css'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const trimmedUsername = username.trim()
|
||||||
|
const trimmedPassword = password.trim()
|
||||||
|
|
||||||
|
if (!trimmedUsername || !trimmedPassword) {
|
||||||
|
setError('Please enter username and password')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = login(trimmedUsername, trimmedPassword)
|
||||||
|
if (success) {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} else {
|
||||||
|
setError('Invalid credentials')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-container">
|
||||||
|
<h1 className="login-title">4NK Waste & Water</h1>
|
||||||
|
<p className="login-subtitle">Simulator</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
|
<div className="login-form-group">
|
||||||
|
<label htmlFor="username" className="login-label">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="login-input"
|
||||||
|
placeholder="Enter username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-form-group">
|
||||||
|
<label htmlFor="password" className="login-label">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="login-input"
|
||||||
|
placeholder="Enter password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
|
||||||
|
<button type="submit" className="login-button">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="login-note">
|
||||||
|
This application is only accessible from localhost
|
||||||
|
</p>
|
||||||
|
<p className="login-note" style={{ marginTop: '8px', fontSize: '0.7rem' }}>
|
||||||
|
Default: admin / admin (or any username/password)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/pages/SettingsPage.css
Normal file
95
src/pages/SettingsPage.css
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
.settings {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button {
|
||||||
|
background-color: var(--primary-green);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button:hover {
|
||||||
|
background-color: var(--primary-green-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button-danger {
|
||||||
|
background-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button-danger:hover {
|
||||||
|
background-color: #DC2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
165
src/pages/SettingsPage.tsx
Normal file
165
src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { exportData, importData, saveStorage } from '@/utils/storage'
|
||||||
|
import { loadSeedData, addSeedDataToExisting } from '@/utils/seedData'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import './SettingsPage.css'
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { data } = useStorage()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const json = exportData()
|
||||||
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `4nkwaste-simulator-${new Date().toISOString().split('T')[0]}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const content = event.target?.result as string
|
||||||
|
const result = importData(content)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert('Data imported successfully!')
|
||||||
|
window.location.reload()
|
||||||
|
} else {
|
||||||
|
alert(`Import failed:\n${result.errors.join('\n')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings">
|
||||||
|
<h1 className="settings-title">Settings</h1>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-section-title">Data Management</h2>
|
||||||
|
|
||||||
|
<div className="settings-card">
|
||||||
|
<h3 className="settings-card-title">Export Data</h3>
|
||||||
|
<p className="settings-card-description">
|
||||||
|
Export all your data as a JSON file for backup or transfer.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleExport} className="settings-button">
|
||||||
|
Export Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-card">
|
||||||
|
<h3 className="settings-card-title">Import Data</h3>
|
||||||
|
<p className="settings-card-description">
|
||||||
|
Import data from a JSON file. This will replace all existing data.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleImport} className="settings-button settings-button-danger">
|
||||||
|
Import Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-section-title">Seed Data</h2>
|
||||||
|
|
||||||
|
<div className="settings-card">
|
||||||
|
<h3 className="settings-card-title">Load Seed Data</h3>
|
||||||
|
<p className="settings-card-description">
|
||||||
|
Load example waste types and regulators to get started. This will add seed data to your existing data.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (data) {
|
||||||
|
const updated = addSeedDataToExisting(data)
|
||||||
|
saveStorage(updated)
|
||||||
|
alert('Seed data loaded successfully!')
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="settings-button"
|
||||||
|
>
|
||||||
|
Load Seed Data (Wastes & Regulators)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-card">
|
||||||
|
<h3 className="settings-card-title">Reset to Seed Data</h3>
|
||||||
|
<p className="settings-card-description">
|
||||||
|
Replace all data with seed data. This will delete all your current data!
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('This will replace all your data with seed data. Continue?')) {
|
||||||
|
const seedData = loadSeedData()
|
||||||
|
saveStorage(seedData)
|
||||||
|
alert('Data reset to seed data!')
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="settings-button settings-button-danger"
|
||||||
|
>
|
||||||
|
Reset to Seed Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-section-title">Data Information</h2>
|
||||||
|
<div className="settings-card">
|
||||||
|
<div className="settings-info">
|
||||||
|
<div className="settings-info-item">
|
||||||
|
<span className="settings-info-label">Version:</span>
|
||||||
|
<span className="settings-info-value">{data?.version || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-info-item">
|
||||||
|
<span className="settings-info-label">Last Modified:</span>
|
||||||
|
<span className="settings-info-value">
|
||||||
|
{data?.lastModified ? new Date(data.lastModified).toLocaleString() : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-info-item">
|
||||||
|
<span className="settings-info-label">Projects:</span>
|
||||||
|
<span className="settings-info-value">{data?.projects.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-info-item">
|
||||||
|
<span className="settings-info-label">Waste Types:</span>
|
||||||
|
<span className="settings-info-value">{data?.wastes.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-info-item">
|
||||||
|
<span className="settings-info-label">Regulators:</span>
|
||||||
|
<span className="settings-info-value">{data?.regulators.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-info-item">
|
||||||
|
<span className="settings-info-label">Treatment Sites:</span>
|
||||||
|
<span className="settings-info-value">{data?.treatmentSites.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-info-item">
|
||||||
|
<span className="settings-info-label">Waste Sites:</span>
|
||||||
|
<span className="settings-info-value">{data?.wasteSites.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/pages/YieldsPage.css
Normal file
84
src/pages/YieldsPage.css
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
.yields-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-card {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message,
|
||||||
|
.error-message {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yields-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yields-card {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-item {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
padding-bottom: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-green);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yield-value-secondary {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-display {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formula-content {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
188
src/pages/YieldsPage.tsx
Normal file
188
src/pages/YieldsPage.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { Project } from '@/types'
|
||||||
|
import { calculateYields, YieldsResult } from '@/utils/calculations/yields'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Select from '@/components/base/Select'
|
||||||
|
import { formatNumber } from '@/utils/formatters'
|
||||||
|
import './YieldsPage.css'
|
||||||
|
|
||||||
|
export default function YieldsPage() {
|
||||||
|
const { data } = useStorage()
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
|
||||||
|
|
||||||
|
const projects = data?.projects || []
|
||||||
|
const wastes = data?.wastes || []
|
||||||
|
const treatmentSites = data?.treatmentSites || []
|
||||||
|
|
||||||
|
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||||
|
const selectedWaste = selectedProject?.wasteCharacteristicsOverride?.wasteId
|
||||||
|
? wastes.find((w) => w.id === selectedProject.wasteCharacteristicsOverride?.wasteId)
|
||||||
|
: undefined
|
||||||
|
const selectedTreatmentSite = selectedProject
|
||||||
|
? treatmentSites.find((s) => s.id === selectedProject.treatmentSiteId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const yields: YieldsResult | null = selectedProject && selectedWaste && selectedTreatmentSite
|
||||||
|
? calculateYields(selectedProject, selectedWaste, selectedTreatmentSite)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const projectOptions = projects.map((project) => ({
|
||||||
|
value: project.id,
|
||||||
|
label: project.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const FormulaDisplay = ({ formula, description }: { formula: string; description: string }) => (
|
||||||
|
<div className="formula-display">
|
||||||
|
<div className="formula-label">{description}</div>
|
||||||
|
<div className="formula-content">{formula}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="yields-page">
|
||||||
|
<h1 className="page-title">Yields</h1>
|
||||||
|
|
||||||
|
<Card className="selector-card">
|
||||||
|
<Select
|
||||||
|
label="Select Project"
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
options={projectOptions.length > 0 ? projectOptions : [{ value: '', label: 'No projects available' }]}
|
||||||
|
helpText="Select a project to view its yields"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!selectedProject && (
|
||||||
|
<Card>
|
||||||
|
<p className="empty-message">Please select a project to view yields</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedProject && (!selectedWaste || !selectedTreatmentSite) && (
|
||||||
|
<Card>
|
||||||
|
<p className="error-message">
|
||||||
|
Cannot calculate yields: {!selectedWaste && 'Waste type not configured. '}
|
||||||
|
{!selectedTreatmentSite && 'Treatment site not found.'}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{yields && (
|
||||||
|
<div className="yields-content">
|
||||||
|
<Card title="Material Outputs" className="yields-card">
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Water</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.water, 2)} t/day</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Water_output = Waste_quantity × Water_percentage × Recovery_rate"
|
||||||
|
description="Water output calculation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Fertilizer</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.fertilizer, 2)} t/day</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Fertilizer_output = Dry_matter × Fertilizer_yield_factor (100%)"
|
||||||
|
description="Fertilizer production calculation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Gas Outputs" className="yields-card">
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Methane (CH₄)</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.methane, 2)} m³/day</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Methane = BMP × Dry_matter (kg VS) × Efficiency_factor (80%)"
|
||||||
|
description="Methane production from BMP"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">CO₂</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.co2, 2)} m³/day</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="CO₂ = Biogas_total × 60% (biogas: 40% CH₄, 60% CO₂)"
|
||||||
|
description="CO₂ production from biogas"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Energy Outputs" className="yields-card">
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Heat Energy</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.heatEnergyKJ, 2)} kJ/day</div>
|
||||||
|
<div className="yield-value-secondary">{formatNumber(yields.heatEnergyKWh, 2)} kW.h/day</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Heat_energy = Methane × 35,800 kJ/m³ × Combustion_efficiency (90%)"
|
||||||
|
description="Heat energy from methane combustion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Electrical Power" className="yields-card">
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">From Biogas Generator</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.electricalPowerBiogas, 2)} kW</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Power_biogas = (Methane × 35,800 kJ/m³ × Electrical_efficiency (40%)) / (3600 × 24)"
|
||||||
|
description="Electrical power from biogas"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">From Solar Panels</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.electricalPowerSolar, 2)} kW</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Power_solar = Panel_surface × Irradiance × Panel_efficiency (20%)"
|
||||||
|
description="Electrical power from solar panels"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Total Electrical Power</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.totalElectricalPower, 2)} kW</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Total_power = Power_biogas + Power_solar"
|
||||||
|
description="Total electrical power"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Modules Consumption</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.modulesConsumption, 2)} kW</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Consumption = 10.5 kW × Number_of_modules"
|
||||||
|
description="Electrical consumption by modules"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Net Electrical Power</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.netElectricalPower, 2)} kW</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Net_power = Total_power - Modules_consumption"
|
||||||
|
description="Net available electrical power"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Bitcoin Mining" className="yields-card">
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Number of 4NK Flex Miners</div>
|
||||||
|
<div className="yield-value">{yields.numberOfFlexMiners}</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="Flex_miners = Net_electrical_power (kW) / 2 kW"
|
||||||
|
description="Number of 2kW flex miners"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yield-item">
|
||||||
|
<div className="yield-label">Bitcoins per Year</div>
|
||||||
|
<div className="yield-value">{formatNumber(yields.bitcoinsPerYear, 6)} BTC/year</div>
|
||||||
|
<FormulaDisplay
|
||||||
|
formula="BTC/year = 79.2 × 0.0001525 / flex_miner"
|
||||||
|
description="Bitcoin production calculation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/pages/configuration/RegulatorsConfigurationPage.css
Normal file
35
src/pages/configuration/RegulatorsConfigurationPage.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.regulators-config-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
206
src/pages/configuration/RegulatorsConfigurationPage.tsx
Normal file
206
src/pages/configuration/RegulatorsConfigurationPage.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { NaturalRegulator } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import { validateRequired, validateNumber } from '@/utils/validators'
|
||||||
|
import './RegulatorsConfigurationPage.css'
|
||||||
|
|
||||||
|
export default function RegulatorsConfigurationPage() {
|
||||||
|
const { data, addEntity, updateEntity, deleteEntity } = useStorage()
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<Partial<NaturalRegulator>>({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
applicationConditions: '',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const regulators = data?.regulators || []
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
newErrors.name = validateRequired(formData.name)
|
||||||
|
newErrors.type = validateRequired(formData.type)
|
||||||
|
newErrors.applicationConditions = validateRequired(formData.applicationConditions)
|
||||||
|
newErrors.dosageMin = validateNumber(formData.dosageRequirements?.min, 0)
|
||||||
|
newErrors.dosageMax = validateNumber(formData.dosageRequirements?.max, formData.dosageRequirements?.min)
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.values(newErrors).some((error) => error !== undefined)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const regulator: NaturalRegulator = {
|
||||||
|
id: editingId || crypto.randomUUID(),
|
||||||
|
name: formData.name!,
|
||||||
|
type: formData.type!,
|
||||||
|
regulatoryCharacteristics: formData.regulatoryCharacteristics,
|
||||||
|
applicationConditions: formData.applicationConditions!,
|
||||||
|
dosageRequirements: formData.dosageRequirements!,
|
||||||
|
createdAt: editingId ? regulators.find((r) => r.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateEntity('regulators', editingId, regulator)
|
||||||
|
} else {
|
||||||
|
addEntity('regulators', regulator)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
applicationConditions: '',
|
||||||
|
dosageRequirements: {
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
unit: 'kg/t',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (regulator: NaturalRegulator) => {
|
||||||
|
setFormData(regulator)
|
||||||
|
setEditingId(regulator.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this regulator?')) {
|
||||||
|
deleteEntity('regulators', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (regulator: NaturalRegulator) => regulator.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
render: (regulator: NaturalRegulator) => regulator.type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dosage',
|
||||||
|
header: 'Dosage',
|
||||||
|
render: (regulator: NaturalRegulator) =>
|
||||||
|
`${regulator.dosageRequirements.min} - ${regulator.dosageRequirements.max} ${regulator.dosageRequirements.unit}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (regulator: NaturalRegulator) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button variant="secondary" onClick={() => handleEdit(regulator)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => handleDelete(regulator.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="regulators-config-page">
|
||||||
|
<h1 className="page-title">Natural Regulators Configuration</h1>
|
||||||
|
|
||||||
|
<div className="page-content">
|
||||||
|
<Card title={editingId ? 'Edit Regulator' : 'Add Regulator'} className="form-card">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Name *"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Type *"
|
||||||
|
value={formData.type || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||||
|
error={errors.type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Application Conditions *"
|
||||||
|
value={formData.applicationConditions || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, applicationConditions: e.target.value })}
|
||||||
|
error={errors.applicationConditions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Dosage Min *"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.dosageRequirements?.min || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
dosageRequirements: {
|
||||||
|
...formData.dosageRequirements!,
|
||||||
|
min: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
error={errors.dosageMin}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Dosage Max *"
|
||||||
|
type="number"
|
||||||
|
min={formData.dosageRequirements?.min || 0}
|
||||||
|
value={formData.dosageRequirements?.max || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
dosageRequirements: {
|
||||||
|
...formData.dosageRequirements!,
|
||||||
|
max: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
error={errors.dosageMax}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
{editingId ? 'Update' : 'Add'} Regulator
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button type="button" variant="secondary" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Natural Regulators" className="table-card">
|
||||||
|
<Table columns={tableColumns} data={regulators} emptyMessage="No regulators configured" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/pages/configuration/ServicesConfigurationPage.css
Normal file
56
src/pages/configuration/ServicesConfigurationPage.css
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
.services-config-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-section {
|
||||||
|
margin: var(--spacing-xl) 0;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
213
src/pages/configuration/ServicesConfigurationPage.tsx
Normal file
213
src/pages/configuration/ServicesConfigurationPage.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { Service, ServiceType } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Select from '@/components/base/Select'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import { formatCurrency } from '@/utils/formatters'
|
||||||
|
import { validateRequired, validateNumber } from '@/utils/validators'
|
||||||
|
import './ServicesConfigurationPage.css'
|
||||||
|
|
||||||
|
export default function ServicesConfigurationPage() {
|
||||||
|
const { data, addEntity, updateEntity, deleteEntity } = useStorage()
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<Partial<Service>>({
|
||||||
|
name: '',
|
||||||
|
type: 'rawRental',
|
||||||
|
pricing: {
|
||||||
|
year1: 0,
|
||||||
|
year2: 0,
|
||||||
|
year3: 0,
|
||||||
|
year4: 0,
|
||||||
|
year5: 0,
|
||||||
|
year6: 0,
|
||||||
|
year7: 0,
|
||||||
|
year8: 0,
|
||||||
|
year9: 0,
|
||||||
|
year10: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const services = data?.services || []
|
||||||
|
|
||||||
|
const serviceTypeOptions: { value: ServiceType; label: string }[] = [
|
||||||
|
{ value: 'rawRental', label: 'Raw Rental' },
|
||||||
|
{ value: 'biologicalTreatment', label: 'Biological Waste Treatment' },
|
||||||
|
{ value: 'bitcoinManagement', label: 'Bitcoin Management' },
|
||||||
|
{ value: 'fertilizers', label: 'Provision of Standardized Fertilizers' },
|
||||||
|
{ value: 'wasteHeat', label: 'Provision of Waste Heat' },
|
||||||
|
{ value: 'carbonCredits', label: 'Provision of Carbon Credit Indices' },
|
||||||
|
{ value: 'brownfield', label: 'Brownfield Redevelopment' },
|
||||||
|
{ value: 'transport', label: 'Transport' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
newErrors.name = validateRequired(formData.name)
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.values(newErrors).some((error) => error !== undefined)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const service: Service = {
|
||||||
|
id: editingId || crypto.randomUUID(),
|
||||||
|
name: formData.name!,
|
||||||
|
type: formData.type!,
|
||||||
|
pricing: formData.pricing!,
|
||||||
|
createdAt: editingId ? services.find((s) => s.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateEntity('services', editingId, service)
|
||||||
|
} else {
|
||||||
|
addEntity('services', service)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
type: 'rawRental',
|
||||||
|
pricing: {
|
||||||
|
year1: 0,
|
||||||
|
year2: 0,
|
||||||
|
year3: 0,
|
||||||
|
year4: 0,
|
||||||
|
year5: 0,
|
||||||
|
year6: 0,
|
||||||
|
year7: 0,
|
||||||
|
year8: 0,
|
||||||
|
year9: 0,
|
||||||
|
year10: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (service: Service) => {
|
||||||
|
setFormData(service)
|
||||||
|
setEditingId(service.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this service?')) {
|
||||||
|
deleteEntity('services', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePricing = (year: keyof Service['pricing'], value: number) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
pricing: {
|
||||||
|
...formData.pricing!,
|
||||||
|
[year]: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (service: Service) => service.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
render: (service: Service) => serviceTypeOptions.find((opt) => opt.value === service.type)?.label || service.type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'year1',
|
||||||
|
header: 'Year 1 (€)',
|
||||||
|
render: (service: Service) => formatCurrency(service.pricing.year1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'year10',
|
||||||
|
header: 'Year 10 (€)',
|
||||||
|
render: (service: Service) => formatCurrency(service.pricing.year10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (service: Service) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button variant="secondary" onClick={() => handleEdit(service)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => handleDelete(service.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="services-config-page">
|
||||||
|
<h1 className="page-title">Services Configuration</h1>
|
||||||
|
|
||||||
|
<div className="page-content">
|
||||||
|
<Card title={editingId ? 'Edit Service' : 'Add Service'} className="form-card">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Name *"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Service Type *"
|
||||||
|
value={formData.type || 'rawRental'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as ServiceType })}
|
||||||
|
options={serviceTypeOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pricing-section">
|
||||||
|
<h3 className="pricing-title">Pricing per Module per Year (€)</h3>
|
||||||
|
<div className="pricing-grid">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((year) => (
|
||||||
|
<Input
|
||||||
|
key={year}
|
||||||
|
label={`Year ${year}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.pricing?.[`year${year}` as keyof Service['pricing']] || ''}
|
||||||
|
onChange={(e) => updatePricing(`year${year}` as keyof Service['pricing'], parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
{editingId ? 'Update' : 'Add'} Service
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button type="button" variant="secondary" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Services" className="table-card">
|
||||||
|
<Table columns={tableColumns} data={services} emptyMessage="No services configured" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/pages/configuration/WasteConfigurationPage.css
Normal file
42
src/pages/configuration/WasteConfigurationPage.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
.waste-config-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
238
src/pages/configuration/WasteConfigurationPage.tsx
Normal file
238
src/pages/configuration/WasteConfigurationPage.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { Waste } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Select from '@/components/base/Select'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import Badge from '@/components/base/Badge'
|
||||||
|
import { formatDate } from '@/utils/formatters'
|
||||||
|
import { validateRequired, validateNumber, validatePercentage } from '@/utils/validators'
|
||||||
|
import './WasteConfigurationPage.css'
|
||||||
|
|
||||||
|
export default function WasteConfigurationPage() {
|
||||||
|
const { data, addEntity, updateEntity, deleteEntity } = useStorage()
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<Partial<Waste>>({
|
||||||
|
name: '',
|
||||||
|
originType: 'animals',
|
||||||
|
originSubType: '',
|
||||||
|
originUnitsPer1000m3Methane: 0,
|
||||||
|
bmp: 0,
|
||||||
|
waterPercentage: 75,
|
||||||
|
regulationNeeds: [],
|
||||||
|
maxStorageDuration: 30,
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const wastes = data?.wastes || []
|
||||||
|
|
||||||
|
const originTypeOptions = [
|
||||||
|
{ value: 'animals', label: 'Animals' },
|
||||||
|
{ value: 'markets', label: 'Markets' },
|
||||||
|
{ value: 'restaurants', label: 'Restaurants' },
|
||||||
|
{ value: 'other', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
newErrors.name = validateRequired(formData.name)
|
||||||
|
newErrors.bmp = validateNumber(formData.bmp, 0.1, 1.0)
|
||||||
|
newErrors.waterPercentage = validatePercentage(formData.waterPercentage)
|
||||||
|
newErrors.originUnitsPer1000m3Methane = validateNumber(formData.originUnitsPer1000m3Methane, 0)
|
||||||
|
newErrors.maxStorageDuration = validateNumber(formData.maxStorageDuration, 1)
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.values(newErrors).some((error) => error !== undefined)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const waste: Waste = {
|
||||||
|
id: editingId || crypto.randomUUID(),
|
||||||
|
name: formData.name!,
|
||||||
|
originType: formData.originType!,
|
||||||
|
originSubType: formData.originSubType,
|
||||||
|
originUnitsPer1000m3Methane: formData.originUnitsPer1000m3Methane!,
|
||||||
|
bmp: formData.bmp!,
|
||||||
|
waterPercentage: formData.waterPercentage!,
|
||||||
|
regulationNeeds: formData.regulationNeeds || [],
|
||||||
|
regulatoryCharacteristics: formData.regulatoryCharacteristics,
|
||||||
|
maxStorageDuration: formData.maxStorageDuration!,
|
||||||
|
createdAt: editingId ? wastes.find((w) => w.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateEntity('wastes', editingId, waste)
|
||||||
|
} else {
|
||||||
|
addEntity('wastes', waste)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
originType: 'animals',
|
||||||
|
originSubType: '',
|
||||||
|
originUnitsPer1000m3Methane: 0,
|
||||||
|
bmp: 0,
|
||||||
|
waterPercentage: 75,
|
||||||
|
regulationNeeds: [],
|
||||||
|
maxStorageDuration: 30,
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (waste: Waste) => {
|
||||||
|
setFormData(waste)
|
||||||
|
setEditingId(waste.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this waste type?')) {
|
||||||
|
deleteEntity('wastes', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (waste: Waste) => waste.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'originType',
|
||||||
|
header: 'Origin Type',
|
||||||
|
render: (waste: Waste) => <Badge variant="info">{waste.originType}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bmp',
|
||||||
|
header: 'BMP (Nm³ CH₄/kg VS)',
|
||||||
|
render: (waste: Waste) => waste.bmp.toFixed(3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waterPercentage',
|
||||||
|
header: 'Water %',
|
||||||
|
render: (waste: Waste) => `${waste.waterPercentage}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxStorageDuration',
|
||||||
|
header: 'Max Storage (days)',
|
||||||
|
render: (waste: Waste) => waste.maxStorageDuration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (waste: Waste) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button variant="secondary" onClick={() => handleEdit(waste)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => handleDelete(waste.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="waste-config-page">
|
||||||
|
<h1 className="page-title">Waste Configuration</h1>
|
||||||
|
|
||||||
|
<div className="page-content">
|
||||||
|
<Card title={editingId ? 'Edit Waste Type' : 'Add Waste Type'} className="form-card">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Name *"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
error={errors.name}
|
||||||
|
placeholder="Enter waste name"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Origin Type *"
|
||||||
|
value={formData.originType || 'animals'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, originType: e.target.value as Waste['originType'] })}
|
||||||
|
options={originTypeOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Origin Sub Type"
|
||||||
|
value={formData.originSubType || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, originSubType: e.target.value })}
|
||||||
|
placeholder="Enter sub type (optional)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="BMP (Nm³ CH₄/kg VS) *"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0.1"
|
||||||
|
max="1.0"
|
||||||
|
value={formData.bmp || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, bmp: parseFloat(e.target.value) || 0 })}
|
||||||
|
error={errors.bmp}
|
||||||
|
helpText="Biochemical Methane Potential (0.1 - 1.0)"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Water Percentage (%) *"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={formData.waterPercentage || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, waterPercentage: parseFloat(e.target.value) || 0 })}
|
||||||
|
error={errors.waterPercentage}
|
||||||
|
helpText="Water content percentage (0-100%)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Origin Units per 1000m³ Methane *"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.originUnitsPer1000m3Methane || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, originUnitsPer1000m3Methane: parseFloat(e.target.value) || 0 })}
|
||||||
|
error={errors.originUnitsPer1000m3Methane}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Max Storage Duration (days) *"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formData.maxStorageDuration || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, maxStorageDuration: parseInt(e.target.value) || 0 })}
|
||||||
|
error={errors.maxStorageDuration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
{editingId ? 'Update' : 'Add'} Waste Type
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button type="button" variant="secondary" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Waste Types" className="table-card">
|
||||||
|
<Table columns={tableColumns} data={wastes} emptyMessage="No waste types configured" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/pages/projects/AdministrativeProceduresPage.css
Normal file
35
src/pages/projects/AdministrativeProceduresPage.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.procedures-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
201
src/pages/projects/AdministrativeProceduresPage.tsx
Normal file
201
src/pages/projects/AdministrativeProceduresPage.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { AdministrativeProcedure, ProcedureType } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Select from '@/components/base/Select'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import { validateRequired, validateNumber } from '@/utils/validators'
|
||||||
|
import './AdministrativeProceduresPage.css'
|
||||||
|
|
||||||
|
export default function AdministrativeProceduresPage() {
|
||||||
|
const { data, addEntity, updateEntity, deleteEntity } = useStorage()
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<Partial<AdministrativeProcedure>>({
|
||||||
|
name: '',
|
||||||
|
type: 'ICPE',
|
||||||
|
delays: 0,
|
||||||
|
contact: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
regions: [],
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const procedures = data?.administrativeProcedures || []
|
||||||
|
|
||||||
|
const procedureTypeOptions = [
|
||||||
|
{ value: 'ICPE', label: 'ICPE' },
|
||||||
|
{ value: 'spreading', label: 'Spreading' },
|
||||||
|
{ value: 'other', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
newErrors.name = validateRequired(formData.name)
|
||||||
|
newErrors.contactName = validateRequired(formData.contact?.name)
|
||||||
|
newErrors.delays = validateNumber(formData.delays, 0)
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.values(newErrors).some((error) => error !== undefined)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const procedure: AdministrativeProcedure = {
|
||||||
|
id: editingId || crypto.randomUUID(),
|
||||||
|
name: formData.name!,
|
||||||
|
type: formData.type!,
|
||||||
|
delays: formData.delays!,
|
||||||
|
contact: formData.contact!,
|
||||||
|
regions: formData.regions || [],
|
||||||
|
createdAt: editingId ? procedures.find((p) => p.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateEntity('administrativeProcedures', editingId, procedure)
|
||||||
|
} else {
|
||||||
|
addEntity('administrativeProcedures', procedure)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
type: 'ICPE',
|
||||||
|
delays: 0,
|
||||||
|
contact: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
regions: [],
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (procedure: AdministrativeProcedure) => {
|
||||||
|
setFormData(procedure)
|
||||||
|
setEditingId(procedure.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this procedure?')) {
|
||||||
|
deleteEntity('administrativeProcedures', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (procedure: AdministrativeProcedure) => procedure.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
render: (procedure: AdministrativeProcedure) => procedure.type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delays',
|
||||||
|
header: 'Delays (days)',
|
||||||
|
render: (procedure: AdministrativeProcedure) => procedure.delays,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contact',
|
||||||
|
header: 'Contact',
|
||||||
|
render: (procedure: AdministrativeProcedure) => procedure.contact.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'regions',
|
||||||
|
header: 'Regions',
|
||||||
|
render: (procedure: AdministrativeProcedure) => procedure.regions.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (procedure: AdministrativeProcedure) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button variant="secondary" onClick={() => handleEdit(procedure)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => handleDelete(procedure.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="procedures-page">
|
||||||
|
<h1 className="page-title">Administrative Procedures</h1>
|
||||||
|
|
||||||
|
<div className="page-content">
|
||||||
|
<Card title={editingId ? 'Edit Procedure' : 'Add Procedure'} className="form-card">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Name *"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Type *"
|
||||||
|
value={formData.type || 'ICPE'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as ProcedureType })}
|
||||||
|
options={procedureTypeOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Delays (days) *"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.delays || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, delays: parseInt(e.target.value) || 0 })}
|
||||||
|
error={errors.delays}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Contact Name *"
|
||||||
|
value={formData.contact?.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
contact: {
|
||||||
|
...formData.contact!,
|
||||||
|
name: e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
error={errors.contactName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
{editingId ? 'Update' : 'Add'} Procedure
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button type="button" variant="secondary" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Administrative Procedures" className="table-card">
|
||||||
|
<Table columns={tableColumns} data={procedures} emptyMessage="No procedures configured" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/pages/projects/InvestorsPage.css
Normal file
35
src/pages/projects/InvestorsPage.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.investors-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
278
src/pages/projects/InvestorsPage.tsx
Normal file
278
src/pages/projects/InvestorsPage.tsx
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { Investor } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import { formatCurrency } from '@/utils/formatters'
|
||||||
|
import { validateRequired, validateNumber } from '@/utils/validators'
|
||||||
|
import './InvestorsPage.css'
|
||||||
|
|
||||||
|
export default function InvestorsPage() {
|
||||||
|
const { data, addEntity, updateEntity, deleteEntity } = useStorage()
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<Partial<Investor>>({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
amountRange: {
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
},
|
||||||
|
geographicRegions: [],
|
||||||
|
wasteRange: {
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
},
|
||||||
|
wasteTypes: [],
|
||||||
|
solarPanelsRange: {
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const investors = data?.investors || []
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
newErrors.name = validateRequired(formData.name)
|
||||||
|
newErrors.type = validateRequired(formData.type)
|
||||||
|
newErrors.amountMin = validateNumber(formData.amountRange?.min, 0)
|
||||||
|
newErrors.amountMax = validateNumber(formData.amountRange?.max, formData.amountRange?.min)
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.values(newErrors).some((error) => error !== undefined)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const investor: Investor = {
|
||||||
|
id: editingId || crypto.randomUUID(),
|
||||||
|
name: formData.name!,
|
||||||
|
type: formData.type!,
|
||||||
|
amountRange: formData.amountRange!,
|
||||||
|
geographicRegions: formData.geographicRegions || [],
|
||||||
|
wasteRange: formData.wasteRange!,
|
||||||
|
wasteTypes: formData.wasteTypes || [],
|
||||||
|
solarPanelsRange: formData.solarPanelsRange!,
|
||||||
|
createdAt: editingId ? investors.find((i) => i.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateEntity('investors', editingId, investor)
|
||||||
|
} else {
|
||||||
|
addEntity('investors', investor)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
amountRange: { min: 0, max: 0 },
|
||||||
|
geographicRegions: [],
|
||||||
|
wasteRange: { min: 0, max: 0 },
|
||||||
|
wasteTypes: [],
|
||||||
|
solarPanelsRange: { min: 0, max: 0 },
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (investor: Investor) => {
|
||||||
|
setFormData(investor)
|
||||||
|
setEditingId(investor.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this investor?')) {
|
||||||
|
deleteEntity('investors', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (investor: Investor) => investor.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
render: (investor: Investor) => investor.type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amountRange',
|
||||||
|
header: 'Amount Range (€)',
|
||||||
|
render: (investor: Investor) => `${formatCurrency(investor.amountRange.min)} - ${formatCurrency(investor.amountRange.max)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'regions',
|
||||||
|
header: 'Regions',
|
||||||
|
render: (investor: Investor) => investor.geographicRegions.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (investor: Investor) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button variant="secondary" onClick={() => handleEdit(investor)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => handleDelete(investor.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="investors-page">
|
||||||
|
<h1 className="page-title">Investors</h1>
|
||||||
|
|
||||||
|
<div className="page-content">
|
||||||
|
<Card title={editingId ? 'Edit Investor' : 'Add Investor'} className="form-card">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Name *"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Type *"
|
||||||
|
value={formData.type || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||||
|
error={errors.type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Amount Min (€) *"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.amountRange?.min || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
amountRange: {
|
||||||
|
...formData.amountRange!,
|
||||||
|
min: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
error={errors.amountMin}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Amount Max (€) *"
|
||||||
|
type="number"
|
||||||
|
min={formData.amountRange?.min || 0}
|
||||||
|
value={formData.amountRange?.max || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
amountRange: {
|
||||||
|
...formData.amountRange!,
|
||||||
|
max: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
error={errors.amountMax}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Waste Range Min (t/day)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.wasteRange?.min || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
wasteRange: {
|
||||||
|
...formData.wasteRange!,
|
||||||
|
min: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Waste Range Max (t/day)"
|
||||||
|
type="number"
|
||||||
|
min={formData.wasteRange?.min || 0}
|
||||||
|
value={formData.wasteRange?.max || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
wasteRange: {
|
||||||
|
...formData.wasteRange!,
|
||||||
|
max: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Solar Panels Min (kW)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.solarPanelsRange?.min || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
solarPanelsRange: {
|
||||||
|
...formData.solarPanelsRange!,
|
||||||
|
min: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Solar Panels Max (kW)"
|
||||||
|
type="number"
|
||||||
|
min={formData.solarPanelsRange?.min || 0}
|
||||||
|
value={formData.solarPanelsRange?.max || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
solarPanelsRange: {
|
||||||
|
...formData.solarPanelsRange!,
|
||||||
|
max: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
{editingId ? 'Update' : 'Add'} Investor
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button type="button" variant="secondary" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Investors" className="table-card">
|
||||||
|
<Table columns={tableColumns} data={investors} emptyMessage="No investors configured" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
src/pages/projects/ProjectConfigurationPage.css
Normal file
82
src/pages/projects/ProjectConfigurationPage.css
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
.project-config-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-section {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.procedure-item,
|
||||||
|
.investment-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr auto;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
390
src/pages/projects/ProjectConfigurationPage.tsx
Normal file
390
src/pages/projects/ProjectConfigurationPage.tsx
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { Project, SiteStatus, ProcedureStatus } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Select from '@/components/base/Select'
|
||||||
|
import Badge from '@/components/base/Badge'
|
||||||
|
import { validateRequired, validateNumber, validateDate, validateDateRange } from '@/utils/validators'
|
||||||
|
import './ProjectConfigurationPage.css'
|
||||||
|
|
||||||
|
export default function ProjectConfigurationPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data, addEntity, updateEntity } = useStorage()
|
||||||
|
const [formData, setFormData] = useState<Partial<Project>>({
|
||||||
|
name: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
treatmentSiteId: '',
|
||||||
|
collectionSiteIds: [],
|
||||||
|
numberOfModules: 1,
|
||||||
|
transportBySite: false,
|
||||||
|
administrativeProcedures: [],
|
||||||
|
investments: [],
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const projects = data?.projects || []
|
||||||
|
const treatmentSites = data?.treatmentSites || []
|
||||||
|
const wasteSites = data?.wasteSites || []
|
||||||
|
const wastes = data?.wastes || []
|
||||||
|
const administrativeProcedures = data?.administrativeProcedures || []
|
||||||
|
const investors = data?.investors || []
|
||||||
|
|
||||||
|
const isEditing = !!id
|
||||||
|
const project = isEditing ? projects.find((p) => p.id === id) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && project) {
|
||||||
|
setFormData(project)
|
||||||
|
} else {
|
||||||
|
// Set default dates (10 years from today)
|
||||||
|
const startDate = new Date().toISOString().split('T')[0]
|
||||||
|
const endDate = new Date()
|
||||||
|
endDate.setFullYear(endDate.getFullYear() + 10)
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
startDate,
|
||||||
|
endDate: endDate.toISOString().split('T')[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [id, project])
|
||||||
|
|
||||||
|
const treatmentSiteOptions = treatmentSites.map((site) => ({
|
||||||
|
value: site.id,
|
||||||
|
label: `${site.name} (${site.status})`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const wasteSiteOptions = wasteSites.map((site) => ({
|
||||||
|
value: site.id,
|
||||||
|
label: site.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const wasteOptions = wastes.map((waste) => ({
|
||||||
|
value: waste.id,
|
||||||
|
label: waste.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
newErrors.name = validateRequired(formData.name)
|
||||||
|
newErrors.startDate = validateDate(formData.startDate)
|
||||||
|
newErrors.endDate = validateDate(formData.endDate)
|
||||||
|
newErrors.treatmentSiteId = validateRequired(formData.treatmentSiteId)
|
||||||
|
newErrors.numberOfModules = validateNumber(formData.numberOfModules, 1)
|
||||||
|
newErrors.dateRange = validateDateRange(formData.startDate, formData.endDate)
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.values(newErrors).some((error) => error !== undefined)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectData: Project = {
|
||||||
|
id: id || crypto.randomUUID(),
|
||||||
|
name: formData.name!,
|
||||||
|
startDate: formData.startDate!,
|
||||||
|
endDate: formData.endDate!,
|
||||||
|
treatmentSiteId: formData.treatmentSiteId!,
|
||||||
|
collectionSiteIds: formData.collectionSiteIds || [],
|
||||||
|
numberOfModules: formData.numberOfModules!,
|
||||||
|
transportBySite: formData.transportBySite || false,
|
||||||
|
wasteCharacteristicsOverride: formData.wasteCharacteristicsOverride,
|
||||||
|
administrativeProcedures: formData.administrativeProcedures || [],
|
||||||
|
investments: formData.investments || [],
|
||||||
|
createdAt: isEditing && project ? project.createdAt : new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
updateEntity('projects', id!, projectData)
|
||||||
|
} else {
|
||||||
|
addEntity('projects', projectData)
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/projects')
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAdministrativeProcedure = () => {
|
||||||
|
if (administrativeProcedures.length === 0) return
|
||||||
|
|
||||||
|
const newProcedures = [...(formData.administrativeProcedures || [])]
|
||||||
|
newProcedures.push({
|
||||||
|
procedureId: administrativeProcedures[0].id,
|
||||||
|
status: 'toDo',
|
||||||
|
})
|
||||||
|
setFormData({ ...formData, administrativeProcedures: newProcedures })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAdministrativeProcedure = (index: number) => {
|
||||||
|
const newProcedures = [...(formData.administrativeProcedures || [])]
|
||||||
|
newProcedures.splice(index, 1)
|
||||||
|
setFormData({ ...formData, administrativeProcedures: newProcedures })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAdministrativeProcedure = (index: number, field: 'procedureId' | 'status', value: string) => {
|
||||||
|
const newProcedures = [...(formData.administrativeProcedures || [])]
|
||||||
|
newProcedures[index] = {
|
||||||
|
...newProcedures[index],
|
||||||
|
[field]: value,
|
||||||
|
}
|
||||||
|
setFormData({ ...formData, administrativeProcedures: newProcedures })
|
||||||
|
}
|
||||||
|
|
||||||
|
const addInvestment = () => {
|
||||||
|
if (investors.length === 0) return
|
||||||
|
|
||||||
|
const newInvestments = [...(formData.investments || [])]
|
||||||
|
newInvestments.push({
|
||||||
|
investorId: investors[0].id,
|
||||||
|
status: 'toBeApproached',
|
||||||
|
amount: 0,
|
||||||
|
})
|
||||||
|
setFormData({ ...formData, investments: newInvestments })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeInvestment = (index: number) => {
|
||||||
|
const newInvestments = [...(formData.investments || [])]
|
||||||
|
newInvestments.splice(index, 1)
|
||||||
|
setFormData({ ...formData, investments: newInvestments })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateInvestment = (index: number, field: 'investorId' | 'status' | 'amount', value: string | number) => {
|
||||||
|
const newInvestments = [...(formData.investments || [])]
|
||||||
|
newInvestments[index] = {
|
||||||
|
...newInvestments[index],
|
||||||
|
[field]: value,
|
||||||
|
}
|
||||||
|
setFormData({ ...formData, investments: newInvestments })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="project-config-page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">{isEditing ? 'Edit Project' : 'Create New Project'}</h1>
|
||||||
|
<Link to="/projects">
|
||||||
|
<Button variant="secondary">Back to Projects</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="project-form">
|
||||||
|
<Card title="Project Information" className="form-section">
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Project Name *"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Number of Modules *"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formData.numberOfModules || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, numberOfModules: parseInt(e.target.value) || 1 })}
|
||||||
|
error={errors.numberOfModules}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Start Date *"
|
||||||
|
type="date"
|
||||||
|
value={formData.startDate || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
|
||||||
|
error={errors.startDate}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="End Date *"
|
||||||
|
type="date"
|
||||||
|
value={formData.endDate || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
|
||||||
|
error={errors.endDate || errors.dateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Sites" className="form-section">
|
||||||
|
<Select
|
||||||
|
label="Treatment Site *"
|
||||||
|
value={formData.treatmentSiteId || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, treatmentSiteId: e.target.value })}
|
||||||
|
options={treatmentSiteOptions.length > 0 ? treatmentSiteOptions : [{ value: '', label: 'No treatment sites available' }]}
|
||||||
|
error={errors.treatmentSiteId}
|
||||||
|
helpText="Create a treatment site first if none available"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="multi-select-section">
|
||||||
|
<label className="input-label">Collection Sites *</label>
|
||||||
|
<div className="checkbox-list">
|
||||||
|
{wasteSiteOptions.map((option) => (
|
||||||
|
<label key={option.value} className="checkbox-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.collectionSiteIds?.includes(option.value) || false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentIds = formData.collectionSiteIds || []
|
||||||
|
if (e.target.checked) {
|
||||||
|
setFormData({ ...formData, collectionSiteIds: [...currentIds, option.value] })
|
||||||
|
} else {
|
||||||
|
setFormData({ ...formData, collectionSiteIds: currentIds.filter((id) => id !== option.value) })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{wasteSiteOptions.length === 0 && (
|
||||||
|
<p className="help-text">No waste sites available. Create waste sites first.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="checkbox-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.transportBySite || false}
|
||||||
|
onChange={(e) => setFormData({ ...formData, transportBySite: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Transport by site</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Waste Configuration Override" className="form-section">
|
||||||
|
<Select
|
||||||
|
label="Waste Type (Override)"
|
||||||
|
value={formData.wasteCharacteristicsOverride?.wasteId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
wasteCharacteristicsOverride: {
|
||||||
|
...formData.wasteCharacteristicsOverride,
|
||||||
|
wasteId: e.target.value || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={[{ value: '', label: 'Use default waste characteristics' }, ...wasteOptions]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="BMP Override (Nm³ CH₄/kg VS)"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0.1"
|
||||||
|
max="1.0"
|
||||||
|
value={formData.wasteCharacteristicsOverride?.bmp || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
wasteCharacteristicsOverride: {
|
||||||
|
...formData.wasteCharacteristicsOverride,
|
||||||
|
bmp: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Water Percentage Override (%)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={formData.wasteCharacteristicsOverride?.waterPercentage || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
wasteCharacteristicsOverride: {
|
||||||
|
...formData.wasteCharacteristicsOverride,
|
||||||
|
waterPercentage: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Administrative Procedures" className="form-section">
|
||||||
|
{formData.administrativeProcedures?.map((proc, index) => (
|
||||||
|
<div key={index} className="procedure-item">
|
||||||
|
<Select
|
||||||
|
label={`Procedure ${index + 1}`}
|
||||||
|
value={proc.procedureId}
|
||||||
|
onChange={(e) => updateAdministrativeProcedure(index, 'procedureId', e.target.value)}
|
||||||
|
options={administrativeProcedures.map((p) => ({ value: p.id, label: p.name }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
value={proc.status}
|
||||||
|
onChange={(e) => updateAdministrativeProcedure(index, 'status', e.target.value as ProcedureStatus)}
|
||||||
|
options={[
|
||||||
|
{ value: 'toDo', label: 'To Do' },
|
||||||
|
{ value: 'done', label: 'Done' },
|
||||||
|
{ value: 'na', label: 'N/A' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="danger" onClick={() => removeAdministrativeProcedure(index)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="secondary" onClick={addAdministrativeProcedure} disabled={administrativeProcedures.length === 0}>
|
||||||
|
Add Procedure
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Investments" className="form-section">
|
||||||
|
{formData.investments?.map((inv, index) => (
|
||||||
|
<div key={index} className="investment-item">
|
||||||
|
<Select
|
||||||
|
label={`Investor ${index + 1}`}
|
||||||
|
value={inv.investorId}
|
||||||
|
onChange={(e) => updateInvestment(index, 'investorId', e.target.value)}
|
||||||
|
options={investors.map((i) => ({ value: i.id, label: i.name }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
value={inv.status}
|
||||||
|
onChange={(e) => updateInvestment(index, 'status', e.target.value as SiteStatus)}
|
||||||
|
options={[
|
||||||
|
{ value: 'toBeApproached', label: 'To be approached' },
|
||||||
|
{ value: 'loiOk', label: 'LOI OK' },
|
||||||
|
{ value: 'inProgress', label: 'In progress' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Amount (€)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={inv.amount || ''}
|
||||||
|
onChange={(e) => updateInvestment(index, 'amount', parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="danger" onClick={() => removeInvestment(index)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="secondary" onClick={addInvestment} disabled={investors.length === 0}>
|
||||||
|
Add Investment
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
{isEditing ? 'Update' : 'Create'} Project
|
||||||
|
</Button>
|
||||||
|
<Link to="/projects">
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/pages/projects/ProjectListPage.css
Normal file
36
src/pages/projects/ProjectListPage.css
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
.project-list-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link {
|
||||||
|
color: var(--primary-green);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
106
src/pages/projects/ProjectListPage.tsx
Normal file
106
src/pages/projects/ProjectListPage.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { Project } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import Badge from '@/components/base/Badge'
|
||||||
|
import { formatDate } from '@/utils/formatters'
|
||||||
|
import './ProjectListPage.css'
|
||||||
|
|
||||||
|
export default function ProjectListPage() {
|
||||||
|
const { data, deleteEntity } = useStorage()
|
||||||
|
|
||||||
|
const projects = data?.projects || []
|
||||||
|
const treatmentSites = data?.treatmentSites || []
|
||||||
|
const wasteSites = data?.wasteSites || []
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this project? This action cannot be undone.')) {
|
||||||
|
deleteEntity('projects', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const statusMap: Record<string, 'to-be-approached' | 'loi-ok' | 'in-progress' | 'completed'> = {
|
||||||
|
toBeApproached: 'to-be-approached',
|
||||||
|
loiOk: 'loi-ok',
|
||||||
|
inProgress: 'in-progress',
|
||||||
|
completed: 'completed',
|
||||||
|
}
|
||||||
|
return statusMap[status] || 'to-be-approached'
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Project Name',
|
||||||
|
render: (project: Project) => (
|
||||||
|
<Link to={`/projects/${project.id}`} className="project-link">
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'treatmentSite',
|
||||||
|
header: 'Treatment Site',
|
||||||
|
render: (project: Project) => {
|
||||||
|
const site = treatmentSites.find((s) => s.id === project.treatmentSiteId)
|
||||||
|
return site ? site.name : 'Unknown'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collectionSites',
|
||||||
|
header: 'Collection Sites',
|
||||||
|
render: (project: Project) => {
|
||||||
|
const sites = project.collectionSiteIds.map((id) => {
|
||||||
|
const site = wasteSites.find((s) => s.id === id)
|
||||||
|
return site?.name
|
||||||
|
}).filter(Boolean)
|
||||||
|
return sites.length > 0 ? sites.join(', ') : 'None'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'modules',
|
||||||
|
header: 'Modules',
|
||||||
|
render: (project: Project) => project.numberOfModules,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dates',
|
||||||
|
header: 'Duration',
|
||||||
|
render: (project: Project) => `${formatDate(project.startDate)} - ${formatDate(project.endDate)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (project: Project) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Link to={`/projects/${project.id}`}>
|
||||||
|
<Button variant="secondary">Edit</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/business-plan?project=${project.id}`}>
|
||||||
|
<Button variant="secondary">Business Plan</Button>
|
||||||
|
</Link>
|
||||||
|
<Button variant="danger" onClick={() => handleDelete(project.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="project-list-page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Projects</h1>
|
||||||
|
<Link to="/projects/new">
|
||||||
|
<Button variant="primary">Create New Project</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="Projects List" className="table-card">
|
||||||
|
<Table columns={tableColumns} data={projects} emptyMessage="No projects created yet" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/pages/projects/TreatmentSitesPage.css
Normal file
56
src/pages/projects/TreatmentSitesPage.css
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
.treatment-sites-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temperatures-section {
|
||||||
|
margin: var(--spacing-xl) 0;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temperatures-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
233
src/pages/projects/TreatmentSitesPage.tsx
Normal file
233
src/pages/projects/TreatmentSitesPage.tsx
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { TreatmentSite, SiteStatus } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Select from '@/components/base/Select'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import Badge from '@/components/base/Badge'
|
||||||
|
import { validateRequired, validateNumber } from '@/utils/validators'
|
||||||
|
import './TreatmentSitesPage.css'
|
||||||
|
|
||||||
|
export default function TreatmentSitesPage() {
|
||||||
|
const { data, addEntity, updateEntity, deleteEntity } = useStorage()
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<Partial<TreatmentSite>>({
|
||||||
|
name: '',
|
||||||
|
status: 'toBeApproached',
|
||||||
|
altitude: 0,
|
||||||
|
availableGroundSurface: 0,
|
||||||
|
monthlyTemperatures: Array(12).fill(0),
|
||||||
|
subscribedServices: [],
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const sites = data?.treatmentSites || []
|
||||||
|
const services = data?.services || []
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'toBeApproached', label: 'To be approached' },
|
||||||
|
{ value: 'loiOk', label: 'LOI OK' },
|
||||||
|
{ value: 'inProgress', label: 'In progress' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
newErrors.name = validateRequired(formData.name)
|
||||||
|
newErrors.altitude = validateNumber(formData.altitude)
|
||||||
|
newErrors.availableGroundSurface = validateNumber(formData.availableGroundSurface, 0)
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.values(newErrors).some((error) => error !== undefined)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const site: TreatmentSite = {
|
||||||
|
id: editingId || crypto.randomUUID(),
|
||||||
|
name: formData.name!,
|
||||||
|
status: formData.status!,
|
||||||
|
location: formData.location,
|
||||||
|
altitude: formData.altitude!,
|
||||||
|
availableGroundSurface: formData.availableGroundSurface!,
|
||||||
|
monthlyTemperatures: formData.monthlyTemperatures!,
|
||||||
|
subscribedServices: formData.subscribedServices || [],
|
||||||
|
createdAt: editingId ? sites.find((s) => s.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateEntity('treatmentSites', editingId, site)
|
||||||
|
} else {
|
||||||
|
addEntity('treatmentSites', site)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
status: 'toBeApproached',
|
||||||
|
altitude: 0,
|
||||||
|
availableGroundSurface: 0,
|
||||||
|
monthlyTemperatures: Array(12).fill(0),
|
||||||
|
subscribedServices: [],
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (site: TreatmentSite) => {
|
||||||
|
setFormData(site)
|
||||||
|
setEditingId(site.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this treatment site?')) {
|
||||||
|
deleteEntity('treatmentSites', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTemperature = (monthIndex: number, value: number) => {
|
||||||
|
const newTemperatures = [...(formData.monthlyTemperatures || Array(12).fill(0))]
|
||||||
|
newTemperatures[monthIndex] = value
|
||||||
|
setFormData({ ...formData, monthlyTemperatures: newTemperatures })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (site: TreatmentSite) => site.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (site: TreatmentSite) => <Badge variant={getStatusVariant(site.status)}>{statusOptions.find(o => o.value === site.status)?.label}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'altitude',
|
||||||
|
header: 'Altitude (m)',
|
||||||
|
render: (site: TreatmentSite) => site.altitude,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'surface',
|
||||||
|
header: 'Surface (m²)',
|
||||||
|
render: (site: TreatmentSite) => site.availableGroundSurface.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'services',
|
||||||
|
header: 'Services',
|
||||||
|
render: (site: TreatmentSite) => site.subscribedServices.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (site: TreatmentSite) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button variant="secondary" onClick={() => handleEdit(site)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => handleDelete(site.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const getStatusVariant = (status: SiteStatus): 'to-be-approached' | 'loi-ok' | 'in-progress' | 'completed' => {
|
||||||
|
const map: Record<SiteStatus, 'to-be-approached' | 'loi-ok' | 'in-progress' | 'completed'> = {
|
||||||
|
toBeApproached: 'to-be-approached',
|
||||||
|
loiOk: 'loi-ok',
|
||||||
|
inProgress: 'in-progress',
|
||||||
|
completed: 'completed',
|
||||||
|
}
|
||||||
|
return map[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="treatment-sites-page">
|
||||||
|
<h1 className="page-title">Treatment Sites</h1>
|
||||||
|
|
||||||
|
<div className="page-content">
|
||||||
|
<Card title={editingId ? 'Edit Treatment Site' : 'Add Treatment Site'} className="form-card">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Name *"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status *"
|
||||||
|
value={formData.status || 'toBeApproached'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as SiteStatus })}
|
||||||
|
options={statusOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Altitude (meters) *"
|
||||||
|
type="number"
|
||||||
|
value={formData.altitude || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, altitude: parseFloat(e.target.value) || 0 })}
|
||||||
|
error={errors.altitude}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Available Ground Surface (m²) *"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.availableGroundSurface || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, availableGroundSurface: parseFloat(e.target.value) || 0 })}
|
||||||
|
error={errors.availableGroundSurface}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="temperatures-section">
|
||||||
|
<h3 className="section-title">Monthly Average Temperatures (°C)</h3>
|
||||||
|
<div className="temperatures-grid">
|
||||||
|
{monthNames.map((month, index) => (
|
||||||
|
<Input
|
||||||
|
key={index}
|
||||||
|
label={month}
|
||||||
|
type="number"
|
||||||
|
value={formData.monthlyTemperatures?.[index] || ''}
|
||||||
|
onChange={(e) => updateTemperature(index, parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
{editingId ? 'Update' : 'Add'} Treatment Site
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button type="button" variant="secondary" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Treatment Sites" className="table-card">
|
||||||
|
<Table columns={tableColumns} data={sites} emptyMessage="No treatment sites configured" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/pages/projects/WasteSitesPage.css
Normal file
35
src/pages/projects/WasteSitesPage.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.waste-sites-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
305
src/pages/projects/WasteSitesPage.tsx
Normal file
305
src/pages/projects/WasteSitesPage.tsx
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useStorage } from '@/hooks/useStorage'
|
||||||
|
import { WasteSite, SiteStatus } from '@/types'
|
||||||
|
import Card from '@/components/base/Card'
|
||||||
|
import Button from '@/components/base/Button'
|
||||||
|
import Input from '@/components/base/Input'
|
||||||
|
import Select from '@/components/base/Select'
|
||||||
|
import Table from '@/components/base/Table'
|
||||||
|
import Badge from '@/components/base/Badge'
|
||||||
|
import { validateRequired, validateNumber } from '@/utils/validators'
|
||||||
|
import './WasteSitesPage.css'
|
||||||
|
|
||||||
|
export default function WasteSitesPage() {
|
||||||
|
const { data, addEntity, updateEntity, deleteEntity } = useStorage()
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<Partial<WasteSite>>({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
status: 'toBeApproached',
|
||||||
|
wasteType: '',
|
||||||
|
quantityRange: {
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
collectionType: '',
|
||||||
|
distance: 0,
|
||||||
|
})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const sites = data?.wasteSites || []
|
||||||
|
const wastes = data?.wastes || []
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'toBeApproached', label: 'To be approached' },
|
||||||
|
{ value: 'loiOk', label: 'LOI OK' },
|
||||||
|
{ value: 'inProgress', label: 'In progress' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wasteOptions = wastes.map((waste) => ({
|
||||||
|
value: waste.id,
|
||||||
|
label: waste.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
newErrors.name = validateRequired(formData.name)
|
||||||
|
newErrors.type = validateRequired(formData.type)
|
||||||
|
newErrors.wasteType = validateRequired(formData.wasteType)
|
||||||
|
newErrors.contactName = validateRequired(formData.contact?.name)
|
||||||
|
newErrors.collectionType = validateRequired(formData.collectionType)
|
||||||
|
newErrors.quantityMin = validateNumber(formData.quantityRange?.min, 0)
|
||||||
|
newErrors.quantityMax = validateNumber(formData.quantityRange?.max, formData.quantityRange?.min)
|
||||||
|
newErrors.distance = validateNumber(formData.distance, 0)
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.values(newErrors).some((error) => error !== undefined)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const site: WasteSite = {
|
||||||
|
id: editingId || crypto.randomUUID(),
|
||||||
|
name: formData.name!,
|
||||||
|
type: formData.type!,
|
||||||
|
status: formData.status!,
|
||||||
|
wasteType: formData.wasteType!,
|
||||||
|
quantityRange: formData.quantityRange!,
|
||||||
|
contact: formData.contact!,
|
||||||
|
collectionType: formData.collectionType!,
|
||||||
|
distance: formData.distance!,
|
||||||
|
createdAt: editingId ? sites.find((s) => s.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateEntity('wasteSites', editingId, site)
|
||||||
|
} else {
|
||||||
|
addEntity('wasteSites', site)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
status: 'toBeApproached',
|
||||||
|
wasteType: '',
|
||||||
|
quantityRange: {
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
collectionType: '',
|
||||||
|
distance: 0,
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (site: WasteSite) => {
|
||||||
|
setFormData(site)
|
||||||
|
setEditingId(site.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this waste site?')) {
|
||||||
|
deleteEntity('wasteSites', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusVariant = (status: SiteStatus): 'to-be-approached' | 'loi-ok' | 'in-progress' | 'completed' => {
|
||||||
|
const map: Record<SiteStatus, 'to-be-approached' | 'loi-ok' | 'in-progress' | 'completed'> = {
|
||||||
|
toBeApproached: 'to-be-approached',
|
||||||
|
loiOk: 'loi-ok',
|
||||||
|
inProgress: 'in-progress',
|
||||||
|
completed: 'completed',
|
||||||
|
}
|
||||||
|
return map[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (site: WasteSite) => site.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
render: (site: WasteSite) => site.type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (site: WasteSite) => <Badge variant={getStatusVariant(site.status)}>{statusOptions.find(o => o.value === site.status)?.label}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wasteType',
|
||||||
|
header: 'Waste Type',
|
||||||
|
render: (site: WasteSite) => {
|
||||||
|
const waste = wastes.find((w) => w.id === site.wasteType)
|
||||||
|
return waste ? waste.name : 'Unknown'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quantity',
|
||||||
|
header: 'Quantity (t/day)',
|
||||||
|
render: (site: WasteSite) => `${site.quantityRange.min} - ${site.quantityRange.max}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'distance',
|
||||||
|
header: 'Distance (km)',
|
||||||
|
render: (site: WasteSite) => site.distance,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (site: WasteSite) => (
|
||||||
|
<div className="table-actions">
|
||||||
|
<Button variant="secondary" onClick={() => handleEdit(site)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => handleDelete(site.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="waste-sites-page">
|
||||||
|
<h1 className="page-title">Waste Sites</h1>
|
||||||
|
|
||||||
|
<div className="page-content">
|
||||||
|
<Card title={editingId ? 'Edit Waste Site' : 'Add Waste Site'} className="form-card">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Name *"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Type *"
|
||||||
|
value={formData.type || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||||
|
error={errors.type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Select
|
||||||
|
label="Status *"
|
||||||
|
value={formData.status || 'toBeApproached'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as SiteStatus })}
|
||||||
|
options={statusOptions}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Waste Type *"
|
||||||
|
value={formData.wasteType || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, wasteType: e.target.value })}
|
||||||
|
options={wasteOptions.length > 0 ? wasteOptions : [{ value: '', label: 'No waste types available' }]}
|
||||||
|
error={errors.wasteType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Quantity Min (t/day) *"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.quantityRange?.min || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
quantityRange: {
|
||||||
|
...formData.quantityRange!,
|
||||||
|
min: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
error={errors.quantityMin}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Quantity Max (t/day) *"
|
||||||
|
type="number"
|
||||||
|
min={formData.quantityRange?.min || 0}
|
||||||
|
value={formData.quantityRange?.max || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
quantityRange: {
|
||||||
|
...formData.quantityRange!,
|
||||||
|
max: parseFloat(e.target.value) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
error={errors.quantityMax}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Input
|
||||||
|
label="Contact Name *"
|
||||||
|
value={formData.contact?.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
contact: {
|
||||||
|
...formData.contact!,
|
||||||
|
name: e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
error={errors.contactName}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Collection Type *"
|
||||||
|
value={formData.collectionType || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, collectionType: e.target.value })}
|
||||||
|
error={errors.collectionType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Distance (km) *"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.distance || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, distance: parseFloat(e.target.value) || 0 })}
|
||||||
|
error={errors.distance}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
{editingId ? 'Update' : 'Add'} Waste Site
|
||||||
|
</Button>
|
||||||
|
{editingId && (
|
||||||
|
<Button type="button" variant="secondary" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Waste Sites" className="table-card">
|
||||||
|
<Table columns={tableColumns} data={sites} emptyMessage="No waste sites configured" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
262
src/types/index.ts
Normal file
262
src/types/index.ts
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
// Base entity types
|
||||||
|
export interface BaseEntity {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt?: string
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// User
|
||||||
|
export interface User extends BaseEntity {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
lastLogin?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waste
|
||||||
|
export interface Waste extends BaseEntity {
|
||||||
|
name: string
|
||||||
|
originType: 'animals' | 'markets' | 'restaurants' | 'other'
|
||||||
|
originSubType?: string
|
||||||
|
originUnitsPer1000m3Methane: number
|
||||||
|
bmp: number // Nm³ CH₄/kg VS
|
||||||
|
waterPercentage: number // 0-100
|
||||||
|
regulationNeeds: string[]
|
||||||
|
regulatoryCharacteristics?: {
|
||||||
|
nitrogen?: number
|
||||||
|
phosphorus?: number
|
||||||
|
potassium?: number
|
||||||
|
carbonNitrogenRatio?: number
|
||||||
|
}
|
||||||
|
maxStorageDuration: number // days
|
||||||
|
}
|
||||||
|
|
||||||
|
// Natural Regulator
|
||||||
|
export interface NaturalRegulator extends BaseEntity {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
regulatoryCharacteristics?: {
|
||||||
|
nitrogen?: number
|
||||||
|
phosphorus?: number
|
||||||
|
potassium?: number
|
||||||
|
carbonNitrogenRatio?: number
|
||||||
|
phAdjustment?: number // -14 to 14
|
||||||
|
metalBinding?: boolean
|
||||||
|
pathogenReduction?: boolean
|
||||||
|
}
|
||||||
|
applicationConditions: string
|
||||||
|
dosageRequirements: {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
unit: 'kg/t' | 'L/t' | '%'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service
|
||||||
|
export type ServiceType =
|
||||||
|
| 'rawRental'
|
||||||
|
| 'biologicalTreatment'
|
||||||
|
| 'bitcoinManagement'
|
||||||
|
| 'fertilizers'
|
||||||
|
| 'wasteHeat'
|
||||||
|
| 'carbonCredits'
|
||||||
|
| 'brownfield'
|
||||||
|
| 'transport'
|
||||||
|
|
||||||
|
export interface Service extends BaseEntity {
|
||||||
|
name: string
|
||||||
|
type: ServiceType
|
||||||
|
pricing: {
|
||||||
|
year1: number
|
||||||
|
year2: number
|
||||||
|
year3: number
|
||||||
|
year4: number
|
||||||
|
year5: number
|
||||||
|
year6: number
|
||||||
|
year7: number
|
||||||
|
year8: number
|
||||||
|
year9: number
|
||||||
|
year10: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treatment Site
|
||||||
|
export type SiteStatus = 'toBeApproached' | 'loiOk' | 'inProgress' | 'completed'
|
||||||
|
|
||||||
|
export interface TreatmentSite extends BaseEntity {
|
||||||
|
name: string
|
||||||
|
status: SiteStatus
|
||||||
|
location?: {
|
||||||
|
address?: string
|
||||||
|
coordinates?: {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
altitude: number // meters
|
||||||
|
availableGroundSurface: number // m²
|
||||||
|
monthlyTemperatures: number[] // 12 values, °C
|
||||||
|
subscribedServices: string[] // service IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waste Site
|
||||||
|
export interface WasteSite extends BaseEntity {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
status: SiteStatus
|
||||||
|
wasteType: string // waste ID
|
||||||
|
quantityRange: {
|
||||||
|
min: number // t/day
|
||||||
|
max: number // t/day
|
||||||
|
}
|
||||||
|
contact: {
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
address?: string
|
||||||
|
}
|
||||||
|
collectionType: string
|
||||||
|
distance: number // km
|
||||||
|
}
|
||||||
|
|
||||||
|
// Investor
|
||||||
|
export interface Investor extends BaseEntity {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
amountRange: {
|
||||||
|
min: number // €
|
||||||
|
max: number // €
|
||||||
|
}
|
||||||
|
geographicRegions: string[]
|
||||||
|
wasteRange: {
|
||||||
|
min: number // t/day
|
||||||
|
max: number // t/day
|
||||||
|
}
|
||||||
|
wasteTypes: string[] // waste IDs
|
||||||
|
solarPanelsRange: {
|
||||||
|
min: number // kW
|
||||||
|
max: number // kW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Administrative Procedure
|
||||||
|
export type ProcedureType = 'ICPE' | 'spreading' | 'other'
|
||||||
|
export type ProcedureStatus = 'toDo' | 'done' | 'na'
|
||||||
|
|
||||||
|
export interface AdministrativeProcedure extends BaseEntity {
|
||||||
|
name: string
|
||||||
|
type: ProcedureType
|
||||||
|
delays: number // days
|
||||||
|
contact: {
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
organization?: string
|
||||||
|
}
|
||||||
|
regions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project
|
||||||
|
export interface ProjectAdministrativeProcedure {
|
||||||
|
procedureId: string
|
||||||
|
status: ProcedureStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectInvestment {
|
||||||
|
investorId: string
|
||||||
|
status: SiteStatus
|
||||||
|
amount: number // €
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WasteCharacteristicsOverride {
|
||||||
|
wasteId?: string
|
||||||
|
bmp?: number
|
||||||
|
waterPercentage?: number
|
||||||
|
regulatoryNeeds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessPlanRevenues {
|
||||||
|
rawRental: number[] // 10 years, €/year
|
||||||
|
biologicalTreatment: number[]
|
||||||
|
bitcoinManagement: number[]
|
||||||
|
fertilizers: number[]
|
||||||
|
wasteHeat: number[]
|
||||||
|
carbonCredits: number[]
|
||||||
|
brownfield: number[]
|
||||||
|
transport: number[]
|
||||||
|
commercialPartnerships: number[]
|
||||||
|
other: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessPlanVariableCosts {
|
||||||
|
rentalServices: number[] // 10 years, €/year
|
||||||
|
commissions: number[]
|
||||||
|
otherVariable: number[]
|
||||||
|
transport: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessPlanFixedCosts {
|
||||||
|
salaries: number[] // 10 years, €/year
|
||||||
|
marketing: number[]
|
||||||
|
rd: number[]
|
||||||
|
administrative: number[]
|
||||||
|
otherGeneral: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessPlanInvestments {
|
||||||
|
equipment: number[] // 10 years, €/year
|
||||||
|
technology: number[]
|
||||||
|
patents: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessPlanUseOfFunds {
|
||||||
|
productDevelopment: number[] // 10 years, €/year
|
||||||
|
marketing: number[]
|
||||||
|
team: number[]
|
||||||
|
structure: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessPlanKPIs {
|
||||||
|
activeUsers: number[] // 10 years
|
||||||
|
cac: number[] // €
|
||||||
|
ltv: number[] // €
|
||||||
|
breakEvenDays: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessPlan {
|
||||||
|
revenues: BusinessPlanRevenues
|
||||||
|
variableCosts: BusinessPlanVariableCosts
|
||||||
|
fixedCosts: BusinessPlanFixedCosts
|
||||||
|
investments: BusinessPlanInvestments
|
||||||
|
useOfFunds: BusinessPlanUseOfFunds
|
||||||
|
kpis: BusinessPlanKPIs
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project extends BaseEntity {
|
||||||
|
name: string
|
||||||
|
startDate: string // ISO 8601
|
||||||
|
endDate: string // ISO 8601
|
||||||
|
treatmentSiteId: string
|
||||||
|
collectionSiteIds: string[]
|
||||||
|
numberOfModules: number
|
||||||
|
transportBySite: boolean
|
||||||
|
wasteCharacteristicsOverride?: WasteCharacteristicsOverride
|
||||||
|
administrativeProcedures: ProjectAdministrativeProcedure[]
|
||||||
|
investments: ProjectInvestment[]
|
||||||
|
businessPlan?: BusinessPlan
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage structure
|
||||||
|
export interface StorageData {
|
||||||
|
version: string
|
||||||
|
lastModified: string
|
||||||
|
users: User[]
|
||||||
|
wastes: Waste[]
|
||||||
|
regulators: NaturalRegulator[]
|
||||||
|
services: Service[]
|
||||||
|
treatmentSites: TreatmentSite[]
|
||||||
|
wasteSites: WasteSite[]
|
||||||
|
investors: Investor[]
|
||||||
|
administrativeProcedures: AdministrativeProcedure[]
|
||||||
|
projects: Project[]
|
||||||
|
}
|
||||||
108
src/utils/calculations/yields.ts
Normal file
108
src/utils/calculations/yields.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Project, Waste, TreatmentSite } from '@/types'
|
||||||
|
import { PHYSICAL_CONSTANTS, EFFICIENCY_FACTORS, GAS_COMPOSITION, MODULE_CONFIG, MODULE_ELECTRICAL_CONSUMPTION, BITCOIN_CONFIG } from '@/utils/constants'
|
||||||
|
|
||||||
|
export interface YieldsResult {
|
||||||
|
water: number // t/day
|
||||||
|
fertilizer: number // t/day
|
||||||
|
methane: number // m³/day
|
||||||
|
co2: number // m³/day
|
||||||
|
heatEnergyKJ: number // kJ/day
|
||||||
|
heatEnergyKWh: number // kW.h/day
|
||||||
|
electricalPowerBiogas: number // kW
|
||||||
|
electricalPowerSolar: number // kW
|
||||||
|
totalElectricalPower: number // kW
|
||||||
|
modulesConsumption: number // kW
|
||||||
|
netElectricalPower: number // kW
|
||||||
|
numberOfFlexMiners: number
|
||||||
|
bitcoinsPerYear: number // BTC/year
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateYields(project: Project, waste: Waste | undefined, treatmentSite: TreatmentSite | undefined): YieldsResult {
|
||||||
|
if (!waste || !treatmentSite) {
|
||||||
|
return getEmptyYields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberOfModules = project.numberOfModules
|
||||||
|
const wastePerModule = MODULE_CONFIG.CAPACITY_PER_MODULE // 67T
|
||||||
|
const totalWaste = wastePerModule * numberOfModules // T/day
|
||||||
|
const waterPercentage = project.wasteCharacteristicsOverride?.waterPercentage || waste.waterPercentage
|
||||||
|
const bmp = project.wasteCharacteristicsOverride?.bmp || waste.bmp
|
||||||
|
|
||||||
|
// Water calculations
|
||||||
|
const waterInput = (totalWaste * waterPercentage) / 100 // T/day
|
||||||
|
const dryMatterPercentage = 100 - waterPercentage
|
||||||
|
const dryMatter = (totalWaste * dryMatterPercentage) / 100 // T/day = kg VS/day (assuming 1T = 1000kg)
|
||||||
|
|
||||||
|
// Methane production
|
||||||
|
const dryMatterKg = dryMatter * 1000 // kg VS/day
|
||||||
|
const methaneProduction = bmp * dryMatterKg * EFFICIENCY_FACTORS.METHANE_PRODUCTION_EFFICIENCY // m³/day
|
||||||
|
|
||||||
|
// Biogas composition (60% CO₂, 40% CH₄)
|
||||||
|
const biogasTotal = methaneProduction / GAS_COMPOSITION.METHANE_PERCENTAGE // m³/day
|
||||||
|
const co2Production = biogasTotal * GAS_COMPOSITION.CO2_PERCENTAGE // m³/day
|
||||||
|
|
||||||
|
// Energy calculations
|
||||||
|
const heatEnergyKJ = methaneProduction * PHYSICAL_CONSTANTS.METHANE_ENERGY_CONTENT * EFFICIENCY_FACTORS.COMBUSTION_EFFICIENCY // kJ/day
|
||||||
|
const heatEnergyKWh = heatEnergyKJ * PHYSICAL_CONSTANTS.KJ_TO_KWH // kW.h/day
|
||||||
|
|
||||||
|
// Electrical power from biogas
|
||||||
|
const electricalPowerBiogas = (methaneProduction * PHYSICAL_CONSTANTS.METHANE_ENERGY_CONTENT * EFFICIENCY_FACTORS.ELECTRICAL_CONVERSION_EFFICIENCY) / (PHYSICAL_CONSTANTS.KWH_TO_KJ * PHYSICAL_CONSTANTS.HOURS_PER_DAY) // kW
|
||||||
|
|
||||||
|
// Electrical power from solar panels (approximate)
|
||||||
|
const solarPanelSurface = treatmentSite.availableGroundSurface * 0.3 // Approximate 30% coverage
|
||||||
|
const averageIrradiance = EFFICIENCY_FACTORS.SOLAR_IRRADIANCE_AVERAGE
|
||||||
|
const electricalPowerSolar = solarPanelSurface * averageIrradiance * EFFICIENCY_FACTORS.SOLAR_PANEL_EFFICIENCY // kW
|
||||||
|
|
||||||
|
// Total electrical power
|
||||||
|
const totalElectricalPower = electricalPowerBiogas + electricalPowerSolar
|
||||||
|
|
||||||
|
// Module consumption
|
||||||
|
const modulesConsumption = MODULE_ELECTRICAL_CONSUMPTION.TOTAL_PER_MODULE * numberOfModules // kW
|
||||||
|
|
||||||
|
// Net electrical power
|
||||||
|
const netElectricalPower = totalElectricalPower - modulesConsumption
|
||||||
|
|
||||||
|
// Bitcoin mining
|
||||||
|
const numberOfFlexMiners = Math.floor(Math.max(0, netElectricalPower) / BITCOIN_CONFIG.POWER_PER_FLEX_MINER)
|
||||||
|
const bitcoinsPerYear = numberOfFlexMiners > 0 ? (BITCOIN_CONFIG.BTC_CALCULATION_CONSTANT * BITCOIN_CONFIG.BTC_PER_FLEX_MINER_FACTOR) / numberOfFlexMiners : 0
|
||||||
|
|
||||||
|
// Water output (simplified - water from spiruline cycle returns to thermophilic)
|
||||||
|
const waterOutput = waterInput * 0.9 // Approximate 90% recovery (10% evaporation)
|
||||||
|
|
||||||
|
// Fertilizer production (100% of compost)
|
||||||
|
const fertilizerOutput = dryMatter * EFFICIENCY_FACTORS.FERTILIZER_YIELD_FACTOR // t/day
|
||||||
|
|
||||||
|
return {
|
||||||
|
water: waterOutput,
|
||||||
|
fertilizer: fertilizerOutput,
|
||||||
|
methane: methaneProduction,
|
||||||
|
co2: co2Production,
|
||||||
|
heatEnergyKJ,
|
||||||
|
heatEnergyKWh,
|
||||||
|
electricalPowerBiogas,
|
||||||
|
electricalPowerSolar,
|
||||||
|
totalElectricalPower,
|
||||||
|
modulesConsumption,
|
||||||
|
netElectricalPower,
|
||||||
|
numberOfFlexMiners,
|
||||||
|
bitcoinsPerYear,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmptyYields(): YieldsResult {
|
||||||
|
return {
|
||||||
|
water: 0,
|
||||||
|
fertilizer: 0,
|
||||||
|
methane: 0,
|
||||||
|
co2: 0,
|
||||||
|
heatEnergyKJ: 0,
|
||||||
|
heatEnergyKWh: 0,
|
||||||
|
electricalPowerBiogas: 0,
|
||||||
|
electricalPowerSolar: 0,
|
||||||
|
totalElectricalPower: 0,
|
||||||
|
modulesConsumption: 0,
|
||||||
|
netElectricalPower: 0,
|
||||||
|
numberOfFlexMiners: 0,
|
||||||
|
bitcoinsPerYear: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
299
src/utils/constants.ts
Normal file
299
src/utils/constants.ts
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Constants and Default Values for 4NK Waste & Water Simulator
|
||||||
|
* All values are realistic defaults that can be adjusted
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PHYSICAL CONSTANTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const PHYSICAL_CONSTANTS = {
|
||||||
|
// Energy content
|
||||||
|
METHANE_ENERGY_CONTENT: 35800, // kJ/m³ (lower heating value)
|
||||||
|
WATER_DENSITY: 1, // t/m³
|
||||||
|
|
||||||
|
// Carbon conversions
|
||||||
|
CARBON_TO_CO2_RATIO: 3.67, // 1 tC = 3.67 tCO₂e
|
||||||
|
CO2_TO_CARBON_RATIO: 0.272, // 1 tCO₂e = 0.272 tC
|
||||||
|
|
||||||
|
// Energy conversions
|
||||||
|
KJ_TO_KWH: 1 / 3600, // 1 kJ = 1/3600 kW.h
|
||||||
|
KWH_TO_KJ: 3600, // 1 kW.h = 3600 kJ
|
||||||
|
|
||||||
|
// Time conversions
|
||||||
|
HOURS_PER_DAY: 24,
|
||||||
|
DAYS_PER_YEAR: 365,
|
||||||
|
DAYS_PER_LEAP_YEAR: 366,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PROCESS EFFICIENCY FACTORS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const EFFICIENCY_FACTORS = {
|
||||||
|
// Anaerobic digestion
|
||||||
|
METHANE_PRODUCTION_EFFICIENCY: 0.80, // 80% of theoretical BMP
|
||||||
|
COMBUSTION_EFFICIENCY: 0.90, // 90% for heat production
|
||||||
|
ELECTRICAL_CONVERSION_EFFICIENCY: 0.40, // 40% for CHP systems
|
||||||
|
|
||||||
|
// Solar panels
|
||||||
|
SOLAR_PANEL_EFFICIENCY: 0.20, // 20% average efficiency
|
||||||
|
SOLAR_IRRADIANCE_AVERAGE: 0.15, // kW/m² average (varies by location)
|
||||||
|
|
||||||
|
// Composting
|
||||||
|
FERTILIZER_YIELD_FACTOR: 1.0, // 100% of compost becomes fertilizer
|
||||||
|
|
||||||
|
// Water processes
|
||||||
|
WATER_EVAPORATION_RATE: 0.005, // m/day (varies with conditions)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GAS COMPOSITION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const GAS_COMPOSITION = {
|
||||||
|
METHANE_PERCENTAGE: 0.40, // 40% methane in biogas
|
||||||
|
CO2_PERCENTAGE: 0.60, // 60% CO₂ in biogas
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MODULE CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const MODULE_CONFIG = {
|
||||||
|
CAPACITY_PER_MODULE: 67, // T of waste at 75% water
|
||||||
|
WATER_PERCENTAGE: 75, // % water in waste
|
||||||
|
CONTAINERS_PER_MODULE: 4,
|
||||||
|
TOTAL_MODULES: 21,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PROCESSING TIMES (days)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const PROCESSING_TIMES = {
|
||||||
|
MESOPHILIC_HYGIENIZATION: 18,
|
||||||
|
MESOPHILIC_ADDITIONAL: 3,
|
||||||
|
MESOPHILIC_TOTAL: 21,
|
||||||
|
|
||||||
|
DRYING: 21,
|
||||||
|
BIOREMEDIATION_PHASES: 3,
|
||||||
|
BIOREMEDIATION_PHASE_DURATION: 21,
|
||||||
|
|
||||||
|
THERMOPHILIC_DIGESTION: 18,
|
||||||
|
COMPOSTING: 3,
|
||||||
|
THERMOPHILIC_COMPOSTING_TOTAL: 21,
|
||||||
|
|
||||||
|
SPIRULINA_CYCLE_DAYS: 21, // 21 days before return to thermophilic
|
||||||
|
SPIRULINA_CYCLE_HOURS: 72, // Initial cycle duration
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ELECTRICAL CONSUMPTION PER MODULE (kW)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const MODULE_ELECTRICAL_CONSUMPTION = {
|
||||||
|
PUMP_METHANISATION: 0.5,
|
||||||
|
SECHOIR_GAZ: 2.0,
|
||||||
|
COMPRESSEUR: 3.0,
|
||||||
|
LAMPE_UV_C_12M: 0.3,
|
||||||
|
RACLOIRES_ELECTRIQUES: 1.5, // 5 × 3 = 1.5 kW
|
||||||
|
POMPE_SPIRULINE: 0.5,
|
||||||
|
LED_CULTURE: 0.6, // 5 × 12m LED
|
||||||
|
POMPES_EAU: 1.5, // 3 pumps
|
||||||
|
CAPTEURS: 0.1,
|
||||||
|
SERVEUR: 0.2,
|
||||||
|
BORNE_STARLINK: 0.1,
|
||||||
|
TABLEAU_ELEC: 0.1,
|
||||||
|
CONVERTISSEUR_SOLAIRE: 0.1,
|
||||||
|
|
||||||
|
// Total per module
|
||||||
|
TOTAL_PER_MODULE: 10.5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BITCOIN MINING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const BITCOIN_CONFIG = {
|
||||||
|
POWER_PER_FLEX_MINER: 2, // kW per 4NK flex miner
|
||||||
|
BTC_PER_FLEX_MINER_FACTOR: 0.0001525,
|
||||||
|
BTC_CALCULATION_CONSTANT: 79.2,
|
||||||
|
BITCOIN_PRICE_EUR: 100000, // €/BTC
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALORIZATION PARAMETERS (€)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const VALORIZATION_PARAMS = {
|
||||||
|
WASTE_TREATMENT_PER_TONNE: 100, // €/t
|
||||||
|
FERTILIZER_PER_TONNE: 215, // €/t
|
||||||
|
HEAT_PER_TONNE: 0.12, // €/t
|
||||||
|
|
||||||
|
// Carbon equivalents
|
||||||
|
CARBON_CH4_BURNED_PER_TC: 630, // €/tC
|
||||||
|
CARBON_CH4_BURNED_PER_TCO2E: 172, // €/tCO₂e (630 / 3.67)
|
||||||
|
CARBON_CO2_SEQUESTERED_PER_TC: 100, // €/tC
|
||||||
|
CARBON_CO2_SEQUESTERED_PER_TCO2E: 27, // €/tCO₂e (100 / 3.67)
|
||||||
|
CARBON_ELECTRICITY_AVOIDED_PER_KW: 0.12, // €/kW
|
||||||
|
|
||||||
|
// Land
|
||||||
|
BROWNFIELD_AREA: 4000, // m²
|
||||||
|
BROWNFIELD_VALORIZATION_RATE: 50, // €/m² (configurable)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION LIMITS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const VALIDATION_LIMITS = {
|
||||||
|
// Percentages
|
||||||
|
WATER_PERCENTAGE_MIN: 0,
|
||||||
|
WATER_PERCENTAGE_MAX: 100,
|
||||||
|
|
||||||
|
// BMP
|
||||||
|
BMP_MIN: 0.1, // Nm³ CH₄/kg VS
|
||||||
|
BMP_MAX: 1.0, // Nm³ CH₄/kg VS
|
||||||
|
|
||||||
|
// Quantities
|
||||||
|
QUANTITY_MIN: 0,
|
||||||
|
QUANTITY_MAX: 100000, // T/day
|
||||||
|
|
||||||
|
// Modules
|
||||||
|
MODULES_MIN: 1,
|
||||||
|
MODULES_MAX: 100,
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
PROJECT_DURATION_MIN_DAYS: 1,
|
||||||
|
PROJECT_DURATION_MAX_DAYS: 3650, // 10 years
|
||||||
|
|
||||||
|
// Financial
|
||||||
|
FINANCIAL_MIN: 0,
|
||||||
|
FINANCIAL_MAX: 1000000000, // 1 billion €
|
||||||
|
|
||||||
|
// Efficiency factors
|
||||||
|
EFFICIENCY_MIN: 0,
|
||||||
|
EFFICIENCY_MAX: 1,
|
||||||
|
|
||||||
|
// Temperature
|
||||||
|
TEMPERATURE_MIN: -50, // °C
|
||||||
|
TEMPERATURE_MAX: 60, // °C
|
||||||
|
|
||||||
|
// Altitude
|
||||||
|
ALTITUDE_MIN: -100, // meters (below sea level)
|
||||||
|
ALTITUDE_MAX: 5000, // meters
|
||||||
|
|
||||||
|
// Surface
|
||||||
|
SURFACE_MIN: 0, // m²
|
||||||
|
SURFACE_MAX: 1000000, // m² (1 km²)
|
||||||
|
|
||||||
|
// Distance
|
||||||
|
DISTANCE_MIN: 0, // km
|
||||||
|
DISTANCE_MAX: 10000, // km
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DEFAULT VALUES FOR CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DEFAULT_VALUES = {
|
||||||
|
// Waste defaults
|
||||||
|
WASTE: {
|
||||||
|
BMP: 0.4, // Nm³ CH₄/kg VS (average)
|
||||||
|
WATER_PERCENTAGE: 75,
|
||||||
|
MAX_STORAGE_DURATION: 30, // days
|
||||||
|
},
|
||||||
|
|
||||||
|
// Processing defaults
|
||||||
|
PROCESSING: {
|
||||||
|
METHANE_EFFICIENCY: EFFICIENCY_FACTORS.METHANE_PRODUCTION_EFFICIENCY,
|
||||||
|
ELECTRICAL_EFFICIENCY: EFFICIENCY_FACTORS.ELECTRICAL_CONVERSION_EFFICIENCY,
|
||||||
|
COMBUSTION_EFFICIENCY: EFFICIENCY_FACTORS.COMBUSTION_EFFICIENCY,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Solar defaults
|
||||||
|
SOLAR: {
|
||||||
|
PANEL_EFFICIENCY: EFFICIENCY_FACTORS.SOLAR_PANEL_EFFICIENCY,
|
||||||
|
IRRADIANCE: EFFICIENCY_FACTORS.SOLAR_IRRADIANCE_AVERAGE,
|
||||||
|
PANEL_SURFACE_PER_CONTAINER: 30, // m² (approximate)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Project defaults
|
||||||
|
PROJECT: {
|
||||||
|
DURATION_YEARS: 10,
|
||||||
|
FIRST_YEAR_PROTOTYPE_DISCOUNT: 0.7, // 70% of standard price
|
||||||
|
},
|
||||||
|
|
||||||
|
// Business plan defaults
|
||||||
|
BUSINESS_PLAN: {
|
||||||
|
GROWTH_RATE_REVENUE: 0.05, // 5% per year
|
||||||
|
INFLATION_RATE_COSTS: 0.02, // 2% per year
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CALCULATION CONSTANTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CALCULATION_CONSTANTS = {
|
||||||
|
// Bitcoin calculation
|
||||||
|
BTC_YEARLY_BASE: BITCOIN_CONFIG.BTC_CALCULATION_CONSTANT,
|
||||||
|
BTC_PER_MINER: BITCOIN_CONFIG.BTC_PER_FLEX_MINER_FACTOR,
|
||||||
|
|
||||||
|
// Water calculations
|
||||||
|
EVAPORATION_SURFACE_FACTOR: 1.0, // m² per module (water wall)
|
||||||
|
|
||||||
|
// Spirulina
|
||||||
|
SPIRULINA_WATER_OUTPUT_PER_CYCLE: 5, // T (example, to be adjusted)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STATUS VALUES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const STATUS_VALUES = {
|
||||||
|
TO_BE_APPROACHED: 'toBeApproached',
|
||||||
|
LOI_OK: 'loiOk',
|
||||||
|
IN_PROGRESS: 'inProgress',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
TO_DO: 'toDo',
|
||||||
|
DONE: 'done',
|
||||||
|
NA: 'na',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SERVICE_TYPES = {
|
||||||
|
RAW_RENTAL: 'rawRental',
|
||||||
|
BIOLOGICAL_TREATMENT: 'biologicalTreatment',
|
||||||
|
BITCOIN_MANAGEMENT: 'bitcoinManagement',
|
||||||
|
FERTILIZERS: 'fertilizers',
|
||||||
|
WASTE_HEAT: 'wasteHeat',
|
||||||
|
CARBON_CREDITS: 'carbonCredits',
|
||||||
|
BROWNFIELD: 'brownfield',
|
||||||
|
TRANSPORT: 'transport',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WASTE ORIGIN TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const WASTE_ORIGIN_TYPES = {
|
||||||
|
ANIMALS: 'animals',
|
||||||
|
MARKETS: 'markets',
|
||||||
|
RESTAURANTS: 'restaurants',
|
||||||
|
OTHER: 'other',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ADMINISTRATIVE PROCEDURE TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const PROCEDURE_TYPES = {
|
||||||
|
ICPE: 'ICPE',
|
||||||
|
SPREADING: 'spreading',
|
||||||
|
OTHER: 'other',
|
||||||
|
} as const;
|
||||||
21
src/utils/formatters.ts
Normal file
21
src/utils/formatters.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export function formatCurrency(value: number): string {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(value: number, decimals: number = 2): string {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString('fr-FR')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleString('fr-FR')
|
||||||
|
}
|
||||||
32
src/utils/seedData.ts
Normal file
32
src/utils/seedData.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { StorageData } from '@/types'
|
||||||
|
import { seedWastes } from '@/data/seedWastes'
|
||||||
|
import { seedRegulators } from '@/data/seedRegulators'
|
||||||
|
import { initializeStorage } from './storage'
|
||||||
|
|
||||||
|
export function loadSeedData(): StorageData {
|
||||||
|
const data = initializeStorage()
|
||||||
|
|
||||||
|
// Add seed wastes
|
||||||
|
data.wastes = seedWastes
|
||||||
|
|
||||||
|
// Add seed regulators
|
||||||
|
data.regulators = seedRegulators
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSeedDataToExisting(data: StorageData): StorageData {
|
||||||
|
// Only add wastes that don't already exist
|
||||||
|
const existingWasteIds = new Set(data.wastes.map(w => w.id))
|
||||||
|
const newWastes = seedWastes.filter(w => !existingWasteIds.has(w.id))
|
||||||
|
|
||||||
|
// Only add regulators that don't already exist
|
||||||
|
const existingRegulatorIds = new Set(data.regulators.map(r => r.id))
|
||||||
|
const newRegulators = seedRegulators.filter(r => !existingRegulatorIds.has(r.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
wastes: [...data.wastes, ...newWastes],
|
||||||
|
regulators: [...data.regulators, ...newRegulators],
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/utils/storage.ts
Normal file
103
src/utils/storage.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { StorageData } from '@/types'
|
||||||
|
|
||||||
|
const STORAGE_KEY = '4nkwaste_simulator_data'
|
||||||
|
const USER_KEY = '4nkwaste_simulator_user'
|
||||||
|
const VERSION_KEY = '4nkwaste_simulator_version'
|
||||||
|
|
||||||
|
const CURRENT_VERSION = '1.0.0'
|
||||||
|
|
||||||
|
// Initialize empty storage
|
||||||
|
export function initializeStorage(): StorageData {
|
||||||
|
return {
|
||||||
|
version: CURRENT_VERSION,
|
||||||
|
lastModified: new Date().toISOString(),
|
||||||
|
users: [],
|
||||||
|
wastes: [],
|
||||||
|
regulators: [],
|
||||||
|
services: [],
|
||||||
|
treatmentSites: [],
|
||||||
|
wasteSites: [],
|
||||||
|
investors: [],
|
||||||
|
administrativeProcedures: [],
|
||||||
|
projects: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data from localStorage
|
||||||
|
export function loadStorage(): StorageData {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!data) {
|
||||||
|
return initializeStorage()
|
||||||
|
}
|
||||||
|
return JSON.parse(data) as StorageData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading storage:', error)
|
||||||
|
return initializeStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save data to localStorage
|
||||||
|
export function saveStorage(data: StorageData): void {
|
||||||
|
try {
|
||||||
|
data.lastModified = new Date().toISOString()
|
||||||
|
data.version = CURRENT_VERSION
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving storage:', error)
|
||||||
|
throw new Error('Failed to save data. Storage may be full.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export data as JSON
|
||||||
|
export function exportData(): string {
|
||||||
|
const data = loadStorage()
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import data from JSON (replaces all data)
|
||||||
|
export function importData(jsonString: string): { success: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonString) as StorageData
|
||||||
|
|
||||||
|
// Validate structure
|
||||||
|
if (!data.version) errors.push('Missing version')
|
||||||
|
if (!Array.isArray(data.wastes)) errors.push('Invalid wastes array')
|
||||||
|
if (!Array.isArray(data.regulators)) errors.push('Invalid regulators array')
|
||||||
|
if (!Array.isArray(data.services)) errors.push('Invalid services array')
|
||||||
|
if (!Array.isArray(data.treatmentSites)) errors.push('Invalid treatmentSites array')
|
||||||
|
if (!Array.isArray(data.wasteSites)) errors.push('Invalid wasteSites array')
|
||||||
|
if (!Array.isArray(data.investors)) errors.push('Invalid investors array')
|
||||||
|
if (!Array.isArray(data.administrativeProcedures)) errors.push('Invalid administrativeProcedures array')
|
||||||
|
if (!Array.isArray(data.projects)) errors.push('Invalid projects array')
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate references (basic check)
|
||||||
|
// TODO: Add more comprehensive validation
|
||||||
|
|
||||||
|
// Replace storage
|
||||||
|
saveStorage(data)
|
||||||
|
return { success: true, errors: [] }
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`Invalid JSON: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
return { success: false, errors }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User session management
|
||||||
|
export function saveUserSession(username: string): void {
|
||||||
|
localStorage.setItem(USER_KEY, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserSession(): string | null {
|
||||||
|
return localStorage.getItem(USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUserSession(): void {
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
}
|
||||||
52
src/utils/validators.ts
Normal file
52
src/utils/validators.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
export function validateRequired(value: string | number | undefined): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return 'This field is required'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNumber(value: string | number | undefined, min?: number, max?: number): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return 'This field is required'
|
||||||
|
}
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : value
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return 'Must be a valid number'
|
||||||
|
}
|
||||||
|
if (min !== undefined && num < min) {
|
||||||
|
return `Must be at least ${min}`
|
||||||
|
}
|
||||||
|
if (max !== undefined && num > max) {
|
||||||
|
return `Must be at most ${max}`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePercentage(value: string | number | undefined): string | undefined {
|
||||||
|
const error = validateNumber(value, 0, 100)
|
||||||
|
if (error) return error
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDate(value: string | undefined): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return 'This field is required'
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return 'Must be a valid date'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDateRange(startDate: string | undefined, endDate: string | undefined): string | undefined {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const start = new Date(startDate)
|
||||||
|
const end = new Date(endDate)
|
||||||
|
if (end < start) {
|
||||||
|
return 'End date must be after start date'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
36
tsconfig.json
Normal file
36
tsconfig.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@/components/*": ["./src/components/*"],
|
||||||
|
"@/pages/*": ["./src/pages/*"],
|
||||||
|
"@/hooks/*": ["./src/hooks/*"],
|
||||||
|
"@/utils/*": ["./src/utils/*"],
|
||||||
|
"@/types/*": ["./src/types/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
334
user_workflow.md
Normal file
334
user_workflow.md
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
# User Workflow and User Journey
|
||||||
|
|
||||||
|
## 1. First Time User Journey
|
||||||
|
|
||||||
|
### 1.1 Initial Setup
|
||||||
|
1. **Application Launch**
|
||||||
|
- User opens application in browser (localhost)
|
||||||
|
- Application checks for existing data
|
||||||
|
- If no data: Show welcome screen with option to start with seed data or empty
|
||||||
|
|
||||||
|
2. **Login**
|
||||||
|
- User sees login page
|
||||||
|
- Enter username and password (first time: create account)
|
||||||
|
- Simple authentication (stored in localStorage)
|
||||||
|
- Redirect to Dashboard
|
||||||
|
|
||||||
|
3. **Dashboard Overview**
|
||||||
|
- User sees empty dashboard or welcome message
|
||||||
|
- Quick access to key actions:
|
||||||
|
- "Create your first project"
|
||||||
|
- "Configure waste types"
|
||||||
|
- "View documentation"
|
||||||
|
|
||||||
|
### 1.2 Recommended Initial Configuration Order
|
||||||
|
1. **Configure Waste Types** (`/configuration/waste`)
|
||||||
|
- Create at least one waste type
|
||||||
|
- Define BMP, water percentage, characteristics
|
||||||
|
- This is needed for projects
|
||||||
|
|
||||||
|
2. **Configure Natural Regulators** (`/configuration/regulators`)
|
||||||
|
- Create regulators if needed for waste treatment
|
||||||
|
- Define characteristics and dosage
|
||||||
|
|
||||||
|
3. **Configure Services** (`/configuration/services`)
|
||||||
|
- Set up service pricing for 10 years
|
||||||
|
- Configure all 8 services
|
||||||
|
- This is needed for business plan calculations
|
||||||
|
|
||||||
|
4. **Create Treatment Site** (`/projects/treatment-sites`)
|
||||||
|
- Create at least one treatment site
|
||||||
|
- Define location, temperatures, surface
|
||||||
|
- Subscribe to services
|
||||||
|
|
||||||
|
5. **Create Waste Site** (`/projects/waste-sites`)
|
||||||
|
- Create waste collection sites
|
||||||
|
- Link to waste types
|
||||||
|
- Define quantities and distance
|
||||||
|
|
||||||
|
6. **Create Project** (`/projects`)
|
||||||
|
- Create first project
|
||||||
|
- Link to treatment site and waste sites
|
||||||
|
- Configure modules and dates
|
||||||
|
|
||||||
|
7. **View Yields** (`/yields`)
|
||||||
|
- See calculated yields for the project
|
||||||
|
- Review formulas and calculations
|
||||||
|
|
||||||
|
8. **Business Plan** (`/business-plan`)
|
||||||
|
- Configure financial data
|
||||||
|
- View projections over 10 years
|
||||||
|
|
||||||
|
## 2. Typical User Workflow
|
||||||
|
|
||||||
|
### 2.1 Creating a New Project
|
||||||
|
|
||||||
|
**Step 1: Project Basic Information**
|
||||||
|
- Navigate to `/projects`
|
||||||
|
- Click "Create New Project"
|
||||||
|
- Fill in:
|
||||||
|
- Project name
|
||||||
|
- Start date - End date
|
||||||
|
- Number of modules
|
||||||
|
|
||||||
|
**Step 2: Link Sites**
|
||||||
|
- Select treatment site (required)
|
||||||
|
- Select one or more waste sites (required)
|
||||||
|
- Configure transport (Yes/No)
|
||||||
|
|
||||||
|
**Step 3: Configure Waste**
|
||||||
|
- Select primary waste type
|
||||||
|
- Optionally override waste characteristics
|
||||||
|
- Add regulatory wastes if needed
|
||||||
|
- Add natural regulators if needed
|
||||||
|
|
||||||
|
**Step 4: Administrative Procedures**
|
||||||
|
- Add required procedures (ICPE, spreading, etc.)
|
||||||
|
- Set status for each procedure
|
||||||
|
|
||||||
|
**Step 5: Investments**
|
||||||
|
- Add investors if applicable
|
||||||
|
- Set status and amount
|
||||||
|
|
||||||
|
**Step 6: View Results**
|
||||||
|
- Navigate to Yields page
|
||||||
|
- Review all calculated outputs
|
||||||
|
- Check formulas and parameters
|
||||||
|
|
||||||
|
**Step 7: Business Plan**
|
||||||
|
- Navigate to Business Plan page
|
||||||
|
- Configure revenues (auto-filled from services)
|
||||||
|
- Configure variable costs
|
||||||
|
- Configure fixed costs
|
||||||
|
- Configure investments
|
||||||
|
- Review financial projections
|
||||||
|
|
||||||
|
### 2.2 Modifying an Existing Project
|
||||||
|
|
||||||
|
1. Navigate to `/projects`
|
||||||
|
2. Click on project to edit
|
||||||
|
3. Modify any field
|
||||||
|
4. Changes are saved automatically (or on blur)
|
||||||
|
5. Recalculations happen automatically
|
||||||
|
6. User can see updated yields and business plan
|
||||||
|
|
||||||
|
### 2.3 Viewing Yields
|
||||||
|
|
||||||
|
1. Navigate to `/yields`
|
||||||
|
2. Select project from dropdown (if multiple projects)
|
||||||
|
3. View all calculated outputs:
|
||||||
|
- Material outputs (water, fertilizer)
|
||||||
|
- Gas outputs (methane, CO₂)
|
||||||
|
- Energy outputs (heat, electricity)
|
||||||
|
- Bitcoin production
|
||||||
|
4. Expand formula sections to see calculations
|
||||||
|
5. Export data if needed
|
||||||
|
|
||||||
|
### 2.4 Analyzing Business Plan
|
||||||
|
|
||||||
|
1. Navigate to `/business-plan`
|
||||||
|
2. Select project
|
||||||
|
3. Review project header (dates, sites, modules)
|
||||||
|
4. Review economic characteristics (year by year)
|
||||||
|
5. Review pricing characteristics (valorizations)
|
||||||
|
6. Analyze KPIs (CAC, LTV, break-even)
|
||||||
|
7. Export report
|
||||||
|
|
||||||
|
## 3. Configuration Workflow
|
||||||
|
|
||||||
|
### 3.1 Configuring Waste Types
|
||||||
|
1. Navigate to `/configuration/waste`
|
||||||
|
2. Click "Add Waste Type"
|
||||||
|
3. Fill in form:
|
||||||
|
- Name
|
||||||
|
- Origin type and subtype
|
||||||
|
- BMP value
|
||||||
|
- Water percentage
|
||||||
|
- Origin units per 1000m³ methane
|
||||||
|
- Regulatory needs
|
||||||
|
- Maximum storage duration
|
||||||
|
4. Save
|
||||||
|
5. Waste type available for projects
|
||||||
|
|
||||||
|
### 3.2 Configuring Services
|
||||||
|
1. Navigate to `/configuration/services`
|
||||||
|
2. Select service to configure
|
||||||
|
3. Enter pricing for each year (1-10)
|
||||||
|
4. First year can have different pricing (prototype)
|
||||||
|
5. Save
|
||||||
|
6. Service pricing used in business plan calculations
|
||||||
|
|
||||||
|
### 3.3 Configuring Treatment Site
|
||||||
|
1. Navigate to `/projects/treatment-sites`
|
||||||
|
2. Click "Add Treatment Site"
|
||||||
|
3. Fill in:
|
||||||
|
- Name
|
||||||
|
- Status
|
||||||
|
- Altitude
|
||||||
|
- Available surface
|
||||||
|
- Monthly temperatures (12 values)
|
||||||
|
- Subscribe to services
|
||||||
|
4. Save
|
||||||
|
5. Site available for projects
|
||||||
|
|
||||||
|
## 4. Data Management Workflow
|
||||||
|
|
||||||
|
### 4.1 Exporting Data
|
||||||
|
1. Navigate to `/settings`
|
||||||
|
2. Click "Export Data"
|
||||||
|
3. JSON file downloads
|
||||||
|
4. Contains all application data
|
||||||
|
|
||||||
|
### 4.2 Importing Data
|
||||||
|
1. Navigate to `/settings`
|
||||||
|
2. Click "Import Data"
|
||||||
|
3. Select JSON file
|
||||||
|
4. System validates data:
|
||||||
|
- Structure validity
|
||||||
|
- Required fields
|
||||||
|
- Value constraints
|
||||||
|
- Reference integrity
|
||||||
|
5. If valid: Replace all data
|
||||||
|
6. If invalid: Show errors, keep existing data
|
||||||
|
|
||||||
|
### 4.3 Backup Strategy
|
||||||
|
- User exports data regularly
|
||||||
|
- Data stored in browser (localStorage/IndexedDB)
|
||||||
|
- Export before major changes
|
||||||
|
- Import to restore previous state
|
||||||
|
|
||||||
|
## 5. Error Handling Workflow
|
||||||
|
|
||||||
|
### 5.1 Form Validation Errors
|
||||||
|
1. User fills form
|
||||||
|
2. Real-time validation (on blur or change)
|
||||||
|
3. Errors shown below fields:
|
||||||
|
- Red border on input
|
||||||
|
- Error message in red
|
||||||
|
- Help text if needed
|
||||||
|
4. User corrects errors
|
||||||
|
5. Errors clear when valid
|
||||||
|
|
||||||
|
### 5.2 Calculation Errors
|
||||||
|
1. System detects calculation error (division by zero, etc.)
|
||||||
|
2. Shows error message in yields section
|
||||||
|
3. Indicates which calculation failed
|
||||||
|
4. Suggests correction (e.g., "Please set number of modules > 0")
|
||||||
|
|
||||||
|
### 5.3 Data Integrity Errors
|
||||||
|
1. User tries to delete entity used in project
|
||||||
|
2. System shows warning:
|
||||||
|
- "This waste type is used in X projects"
|
||||||
|
- Option to cancel or force delete
|
||||||
|
3. If force delete: Remove from projects or set to null
|
||||||
|
|
||||||
|
### 5.4 Import Errors
|
||||||
|
1. User imports invalid JSON
|
||||||
|
2. System shows detailed error list:
|
||||||
|
- Which entity has errors
|
||||||
|
- Which fields are invalid
|
||||||
|
- Which references are broken
|
||||||
|
3. User fixes JSON and retries
|
||||||
|
|
||||||
|
## 6. Navigation Patterns
|
||||||
|
|
||||||
|
### 6.1 Primary Navigation
|
||||||
|
- **Sidebar**: Always visible, main sections
|
||||||
|
- **Breadcrumbs**: Show current location
|
||||||
|
- **Header**: Project selector, user info, logout
|
||||||
|
|
||||||
|
### 6.2 Quick Actions
|
||||||
|
- **Dashboard**: Quick links to common actions
|
||||||
|
- **Project List**: Quick actions (Edit, View BP, Delete)
|
||||||
|
- **Contextual Actions**: Buttons in relevant sections
|
||||||
|
|
||||||
|
### 6.3 Deep Linking
|
||||||
|
- All pages have unique URLs
|
||||||
|
- Can bookmark specific projects
|
||||||
|
- Can share URLs (within localhost)
|
||||||
|
|
||||||
|
## 7. User Feedback and Confirmation
|
||||||
|
|
||||||
|
### 7.1 Success Messages
|
||||||
|
- "Project created successfully"
|
||||||
|
- "Data exported successfully"
|
||||||
|
- "Configuration saved"
|
||||||
|
- Toast notifications (top-right, auto-dismiss)
|
||||||
|
|
||||||
|
### 7.2 Confirmation Dialogs
|
||||||
|
- Delete operations: "Are you sure you want to delete this project?"
|
||||||
|
- Import data: "This will replace all existing data. Continue?"
|
||||||
|
- Logout: "Are you sure you want to logout?"
|
||||||
|
|
||||||
|
### 7.3 Loading States
|
||||||
|
- Form submission: Button shows spinner
|
||||||
|
- Calculations: "Calculating..." message
|
||||||
|
- Data load: Skeleton loaders
|
||||||
|
|
||||||
|
### 7.4 Empty States
|
||||||
|
- No projects: "Create your first project"
|
||||||
|
- No yields: "Configure a project to see yields"
|
||||||
|
- No data: "Import seed data or start configuring"
|
||||||
|
|
||||||
|
## 8. Recommended Order for New Users
|
||||||
|
|
||||||
|
### Day 1: Setup
|
||||||
|
1. Login
|
||||||
|
2. Configure 2-3 waste types
|
||||||
|
3. Configure 1-2 natural regulators
|
||||||
|
4. Configure all 8 services (with default pricing)
|
||||||
|
5. Create 1 treatment site
|
||||||
|
6. Create 1-2 waste sites
|
||||||
|
|
||||||
|
### Day 2: First Project
|
||||||
|
1. Create first project
|
||||||
|
2. Link sites
|
||||||
|
3. Configure waste
|
||||||
|
4. View yields
|
||||||
|
5. Configure business plan
|
||||||
|
6. Review results
|
||||||
|
|
||||||
|
### Day 3: Refinement
|
||||||
|
1. Adjust configurations
|
||||||
|
2. Create additional projects
|
||||||
|
3. Compare scenarios
|
||||||
|
4. Export data
|
||||||
|
|
||||||
|
## 9. Power User Workflow
|
||||||
|
|
||||||
|
### 9.1 Multiple Projects
|
||||||
|
- Create multiple projects with different configurations
|
||||||
|
- Compare yields between projects
|
||||||
|
- Compare business plans
|
||||||
|
- Use project selector in header
|
||||||
|
|
||||||
|
### 9.2 Advanced Configuration
|
||||||
|
- Override waste characteristics per project
|
||||||
|
- Customize service pricing per project
|
||||||
|
- Configure complex waste mixtures
|
||||||
|
- Multiple regulatory wastes and natural regulators
|
||||||
|
|
||||||
|
### 9.3 Data Analysis
|
||||||
|
- Export data for external analysis
|
||||||
|
- Import modified data
|
||||||
|
- Version control via exports
|
||||||
|
- Backup before major changes
|
||||||
|
|
||||||
|
## 10. Help and Documentation
|
||||||
|
|
||||||
|
### 10.1 Contextual Help
|
||||||
|
- Help icons next to complex fields
|
||||||
|
- Tooltips on hover
|
||||||
|
- Formula explanations inline
|
||||||
|
|
||||||
|
### 10.2 Documentation Access
|
||||||
|
- Help page (`/help`) with:
|
||||||
|
- User guide
|
||||||
|
- Formula reference
|
||||||
|
- FAQ
|
||||||
|
- Examples
|
||||||
|
|
||||||
|
### 10.3 Onboarding
|
||||||
|
- First-time user: Show tooltips for key features
|
||||||
|
- Optional: Skip onboarding
|
||||||
|
- Can restart onboarding from settings
|
||||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 3000,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user