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:
Nicolas Cantu 2025-12-09 19:09:42 +01:00
commit c7db6590f0
78 changed files with 15045 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

406
data_schemas.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

56
src/App.tsx Normal file
View 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

View 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);
}

View 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>
}

View 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;
}

View 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>
)
}

View 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);
}

View 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>
)
}

View 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;
}

View 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

View 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;
}

View 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

View 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;
}

View 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>
)
}

View 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);
}

View 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>
)
}

View 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;
}

View 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>
)
}

View 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;
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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);
}

View 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
View 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);
}

View 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
View 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
View 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
View 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
View 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>
)
}

View 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
View 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
View 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
View 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>
)
}

View 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);
}

View 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>
)
}

View 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);
}

View 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>
)
}

View 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);
}

View 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>
)
}

View 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);
}

View 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>
)
}

View 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);
}

View 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>
)
}

View 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);
}

View 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>
)
}

View 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;
}

View 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>
)
}

View 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);
}

View 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>
)
}

View 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);
}

View 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
View 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[]
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

36
tsconfig.json Normal file
View 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
View 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
View 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
View 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'),
},
},
})