**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
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|