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