From 7b8d2b1abb2c7016955d3d6e3302a91add4fdf6c Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Wed, 10 Dec 2025 08:04:23 +0100 Subject: [PATCH] Add transporters to sites and services to module components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Motivations :** * Allow associating transporters to waste sites and treatment sites * Allow associating services to module components * Improve data relationships for better project management **Evolutions :** * Added SiteTransporterAssociation interface for site-transporter relationships * Added ModuleComponentServiceAssociation interface for component-service relationships * Added transporters field to TreatmentSite and WasteSite interfaces * Added services field to ModuleComponent interface * Updated WasteSitesPage and TreatmentSitesPage to manage transporters with primary flag and notes * Updated ModuleComponentsConfigurationPage to manage services with notes * Added CSS styles for transporter and service association sections * Added transporters and services columns in respective tables **Pages affectées :** * src/pages/projects/WasteSitesPage.tsx * src/pages/projects/TreatmentSitesPage.tsx * src/pages/configuration/ModuleComponentsConfigurationPage.tsx --- .../ModuleComponentsConfigurationPage.css | 55 ++++ .../ModuleComponentsConfigurationPage.tsx | 242 +++++++++++++++++- src/pages/projects/TreatmentSitesPage.css | 56 ++++ src/pages/projects/TreatmentSitesPage.tsx | 131 +++++++++- src/pages/projects/WasteSitesPage.css | 56 ++++ src/pages/projects/WasteSitesPage.tsx | 131 +++++++++- src/types/index.ts | 28 ++ 7 files changed, 696 insertions(+), 3 deletions(-) diff --git a/src/pages/configuration/ModuleComponentsConfigurationPage.css b/src/pages/configuration/ModuleComponentsConfigurationPage.css index 6c60c8a..cc1171b 100644 --- a/src/pages/configuration/ModuleComponentsConfigurationPage.css +++ b/src/pages/configuration/ModuleComponentsConfigurationPage.css @@ -134,3 +134,58 @@ display: flex; gap: var(--spacing-sm); } + +.regulators-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-top: var(--spacing-md); +} + +.regulator-association-item { + display: grid; + grid-template-columns: 2fr 1fr 1fr auto; + gap: var(--spacing-md); + align-items: flex-end; + padding: var(--spacing-sm); + border: 1px solid var(--border); + border-radius: 8px; +} + +.regulators-summary { + margin-top: var(--spacing-md); + padding: var(--spacing-sm); + background-color: var(--background-secondary); + border-radius: 8px; + text-align: right; +} + +.regulators-summary .error-text { + display: block; + color: var(--error); + margin-top: var(--spacing-xs); + font-size: 0.875rem; +} + +.form-help-text { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: var(--spacing-md); +} + +.services-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-top: var(--spacing-md); +} + +.service-association-item { + display: grid; + grid-template-columns: 2fr 2fr auto; + gap: var(--spacing-md); + align-items: flex-end; + padding: var(--spacing-sm); + border: 1px solid var(--border); + border-radius: 8px; +} diff --git a/src/pages/configuration/ModuleComponentsConfigurationPage.tsx b/src/pages/configuration/ModuleComponentsConfigurationPage.tsx index 35f7434..8dcddfb 100644 --- a/src/pages/configuration/ModuleComponentsConfigurationPage.tsx +++ b/src/pages/configuration/ModuleComponentsConfigurationPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useStorage } from '@/hooks/useStorage' -import { ModuleComponent, ModuleComponentType, MonthlyValue, Dimensions, PowerSpecs, ProductionPowerSpecs } from '@/types' +import { ModuleComponent, ModuleComponentType, MonthlyValue, Dimensions, PowerSpecs, ProductionPowerSpecs, ModuleComponentRegulatorAssociation, ModuleComponentServiceAssociation } from '@/types' import Card from '@/components/base/Card' import Button from '@/components/base/Button' import Input from '@/components/base/Input' @@ -54,11 +54,15 @@ export default function ModuleComponentsConfigurationPage() { isOptional: false, defaultDuration: 21, durationUnit: 'days', + regulators: [], + services: [], }) const [errors, setErrors] = useState>({}) const components = data?.moduleComponents || [] const wastes = data?.wastes || [] + const regulators = data?.regulators || [] + const services = data?.services || [] const selectedComponentType = COMPONENT_TYPES.find(t => t.value === formData.type) @@ -84,6 +88,12 @@ export default function ModuleComponentsConfigurationPage() { newErrors.type = validateRequired(formData.type) newErrors.defaultDuration = validateNumber(formData.defaultDuration, 0) + // Validate total percentage of associated regulators + const totalRegulatorPercentage = (formData.regulators || []).reduce((sum, a) => sum + (a.percentage || 0), 0) + if (totalRegulatorPercentage > 100) { + newErrors.regulators = 'Total regulator percentage cannot exceed 100%' + } + setErrors(newErrors) if (Object.values(newErrors).some((error) => error !== undefined)) { @@ -122,6 +132,8 @@ export default function ModuleComponentsConfigurationPage() { annualExploitationConsumablesCost: formData.annualExploitationConsumablesCost, annualMaintenanceConsumablesCost: formData.annualMaintenanceConsumablesCost, lifetime: formData.lifetime, + regulators: formData.regulators || [], + services: formData.services || [], createdAt: editingId ? components.find((c) => c.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(), updatedAt: new Date().toISOString(), } @@ -142,6 +154,7 @@ export default function ModuleComponentsConfigurationPage() { isOptional: false, defaultDuration: 21, durationUnit: 'days', + regulators: [], }) setEditingId(null) setErrors({}) @@ -209,6 +222,48 @@ export default function ModuleComponentsConfigurationPage() { header: 'Duration', render: (component: ModuleComponent) => `${component.defaultDuration} ${component.durationUnit}`, }, + { + key: 'regulators', + header: 'Regulators', + render: (component: ModuleComponent) => { + if (!component.regulators || component.regulators.length === 0) { + return None + } + return ( +
+ {component.regulators.map((assoc, idx) => { + const regulator = regulators.find((r) => r.id === assoc.regulatorId) + return ( + + {regulator?.name || 'Unknown'} ({assoc.percentage}%) + + ) + })} +
+ ) + }, + }, + { + key: 'services', + header: 'Services', + render: (component: ModuleComponent) => { + if (!component.services || component.services.length === 0) { + return None + } + return ( +
+ {component.services.map((assoc, idx) => { + const service = services.find((s) => s.id === assoc.serviceId) + return ( + + {service?.name || 'Unknown'} + + ) + })} +
+ ) + }, + }, { key: 'actions', header: 'Actions', @@ -635,6 +690,191 @@ export default function ModuleComponentsConfigurationPage() { + {/* Associated Regulators Section */} +
+

Associated Regulators

+

+ Add regulators to this module component. Specify the percentage of total volume for each regulator. +

+
+ {(formData.regulators || []).map((association, index) => { + const regulator = regulators.find((r) => r.id === association.regulatorId) + return ( +
+ { + const updatedRegulators = [...(formData.regulators || [])] + updatedRegulators[index] = { + ...updatedRegulators[index], + percentage: parseFloat(e.target.value) || 0, + } + setFormData({ ...formData, regulators: updatedRegulators }) + }} + helpText={regulator ? `Regulator: ${regulator.name}` : undefined} + /> + { + const updatedRegulators = [...(formData.regulators || [])] + updatedRegulators[index] = { + ...updatedRegulators[index], + dosage: e.target.value ? parseFloat(e.target.value) : undefined, + } + setFormData({ ...formData, regulators: updatedRegulators }) + }} + helpText="Specific dosage (kg/t, L/t, or %)" + /> + +
+ ) + })} + + {(formData.regulators || []).length > 0 && ( +
+ + Total: {(formData.regulators || []).reduce((sum, a) => sum + (a.percentage || 0), 0).toFixed(1)}% + + {errors.regulators && {errors.regulators}} +
+ )} +
+
+ + {/* Associated Services Section */} +
+

Associated Services

+

+ Add services to this module component. Services can be used for business plan calculations. +

+
+ {(formData.services || []).map((association, index) => { + const service = services.find((s) => s.id === association.serviceId) + return ( +
+ { + const updatedServices = [...(formData.services || [])] + updatedServices[index] = { + ...updatedServices[index], + notes: e.target.value || undefined, + } + setFormData({ ...formData, services: updatedServices }) + }} + helpText={service ? `Type: ${service.type}` : undefined} + /> + +
+ ) + })} + +
+
+ {/* Costs Section */}

Costs (Annual)

diff --git a/src/pages/projects/TreatmentSitesPage.css b/src/pages/projects/TreatmentSitesPage.css index d74b559..7088e6a 100644 --- a/src/pages/projects/TreatmentSitesPage.css +++ b/src/pages/projects/TreatmentSitesPage.css @@ -54,3 +54,59 @@ display: flex; gap: var(--spacing-sm); } + +.form-section { + margin-top: var(--spacing-xl); + padding-top: var(--spacing-xl); + border-top: 1px solid var(--border); +} + +.form-section-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--spacing-md); +} + +.form-help-text { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: var(--spacing-md); +} + +.transporters-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-top: var(--spacing-md); +} + +.transporter-association-item { + display: grid; + grid-template-columns: 2fr auto 2fr auto; + gap: var(--spacing-md); + align-items: flex-end; + padding: var(--spacing-sm); + border: 1px solid var(--border); + border-radius: 8px; +} + +.form-checkbox { + display: flex; + align-items: center; + padding-top: var(--spacing-md); +} + +.form-checkbox label { + display: flex; + align-items: center; + gap: var(--spacing-sm); + cursor: pointer; +} + +.form-checkbox input[type="checkbox"] { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: var(--primary-green); +} \ No newline at end of file diff --git a/src/pages/projects/TreatmentSitesPage.tsx b/src/pages/projects/TreatmentSitesPage.tsx index a04c34c..6f96321 100644 --- a/src/pages/projects/TreatmentSitesPage.tsx +++ b/src/pages/projects/TreatmentSitesPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useStorage } from '@/hooks/useStorage' -import { TreatmentSite, SiteStatus } from '@/types' +import { TreatmentSite, SiteStatus, SiteTransporterAssociation } from '@/types' import Card from '@/components/base/Card' import Button from '@/components/base/Button' import Input from '@/components/base/Input' @@ -20,11 +20,13 @@ export default function TreatmentSitesPage() { availableGroundSurface: 0, monthlyTemperatures: Array(12).fill(0), subscribedServices: [], + transporters: [], }) const [errors, setErrors] = useState>({}) const sites = data?.treatmentSites || [] const services = data?.services || [] + const transporters = data?.transporters || [] const statusOptions = [ { value: 'toBeApproached', label: 'To be approached' }, @@ -61,6 +63,7 @@ export default function TreatmentSitesPage() { availableGroundSurface: formData.availableGroundSurface!, monthlyTemperatures: formData.monthlyTemperatures!, subscribedServices: formData.subscribedServices || [], + transporters: formData.transporters || [], createdAt: editingId ? sites.find((s) => s.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(), updatedAt: new Date().toISOString(), } @@ -82,6 +85,7 @@ export default function TreatmentSitesPage() { availableGroundSurface: 0, monthlyTemperatures: Array(12).fill(0), subscribedServices: [], + transporters: [], }) setEditingId(null) setErrors({}) @@ -130,6 +134,27 @@ export default function TreatmentSitesPage() { header: 'Services', render: (site: TreatmentSite) => site.subscribedServices.length, }, + { + key: 'transporters', + header: 'Transporters', + render: (site: TreatmentSite) => { + if (!site.transporters || site.transporters.length === 0) { + return None + } + return ( +
+ {site.transporters.map((assoc, idx) => { + const transporter = transporters.find((t) => t.id === assoc.transporterId) + return ( + + {transporter?.name || 'Unknown'} {assoc.isPrimary && '(Primary)'} + + ) + })} +
+ ) + }, + }, { key: 'actions', header: 'Actions', @@ -211,6 +236,110 @@ export default function TreatmentSitesPage() {
+ {/* Associated Transporters Section */} +
+

Associated Transporters

+

+ Add transporters for this treatment site. Mark one as primary if needed. +

+
+ {(formData.transporters || []).map((association, index) => { + const transporter = transporters.find((t) => t.id === association.transporterId) + return ( +
+ { + const updatedTransporters = [...(formData.transporters || [])] + updatedTransporters[index] = { + ...updatedTransporters[index], + isPrimary: e.target.checked, + } + // If this is set as primary, unset others + if (e.target.checked) { + updatedTransporters.forEach((t, i) => { + if (i !== index) { + t.isPrimary = false + } + }) + } + setFormData({ ...formData, transporters: updatedTransporters }) + }} + /> + Primary + +
+ { + const updatedTransporters = [...(formData.transporters || [])] + updatedTransporters[index] = { + ...updatedTransporters[index], + notes: e.target.value || undefined, + } + setFormData({ ...formData, transporters: updatedTransporters }) + }} + helpText={transporter ? `Type: ${transporter.type}, Capacity: ${transporter.capacity}` : undefined} + /> + +
+ ) + })} + +
+ +
+
+ ) + })} + + + +