Add transporters to sites and services to module components

**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
This commit is contained in:
Nicolas Cantu 2025-12-10 08:04:23 +01:00
parent 0236c927e7
commit 7b8d2b1abb
7 changed files with 696 additions and 3 deletions

View File

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

View File

@ -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<Record<string, string>>({})
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 <span style={{ color: 'var(--text-secondary)' }}>None</span>
}
return (
<div>
{component.regulators.map((assoc, idx) => {
const regulator = regulators.find((r) => r.id === assoc.regulatorId)
return (
<Badge key={idx} variant="info" style={{ marginRight: '4px' }}>
{regulator?.name || 'Unknown'} ({assoc.percentage}%)
</Badge>
)
})}
</div>
)
},
},
{
key: 'services',
header: 'Services',
render: (component: ModuleComponent) => {
if (!component.services || component.services.length === 0) {
return <span style={{ color: 'var(--text-secondary)' }}>None</span>
}
return (
<div>
{component.services.map((assoc, idx) => {
const service = services.find((s) => s.id === assoc.serviceId)
return (
<Badge key={idx} variant="success" style={{ marginRight: '4px' }}>
{service?.name || 'Unknown'}
</Badge>
)
})}
</div>
)
},
},
{
key: 'actions',
header: 'Actions',
@ -635,6 +690,191 @@ export default function ModuleComponentsConfigurationPage() {
</div>
</div>
{/* Associated Regulators Section */}
<div className="form-section">
<h3 className="form-section-title">Associated Regulators</h3>
<p className="form-help-text">
Add regulators to this module component. Specify the percentage of total volume for each regulator.
</p>
<div className="regulators-list">
{(formData.regulators || []).map((association, index) => {
const regulator = regulators.find((r) => r.id === association.regulatorId)
return (
<div key={index} className="regulator-association-item">
<Select
label="Regulator"
value={association.regulatorId || ''}
onChange={(e) => {
const updatedRegulators = [...(formData.regulators || [])]
updatedRegulators[index] = {
...updatedRegulators[index],
regulatorId: e.target.value,
}
setFormData({ ...formData, regulators: updatedRegulators })
}}
options={[
{ value: '', label: 'Select a regulator' },
...regulators.map((r) => ({
value: r.id,
label: r.name,
})).filter(
(opt) =>
opt.value === association.regulatorId ||
!(formData.regulators || []).some((a) => a.regulatorId === opt.value)
),
]}
/>
<Input
label="Volume Percentage (%)"
type="number"
min="0"
max="100"
step="0.1"
value={association.percentage || ''}
onChange={(e) => {
const updatedRegulators = [...(formData.regulators || [])]
updatedRegulators[index] = {
...updatedRegulators[index],
percentage: parseFloat(e.target.value) || 0,
}
setFormData({ ...formData, regulators: updatedRegulators })
}}
helpText={regulator ? `Regulator: ${regulator.name}` : undefined}
/>
<Input
label="Dosage (optional)"
type="number"
min="0"
step="0.01"
value={association.dosage || ''}
onChange={(e) => {
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 %)"
/>
<Button
type="button"
variant="danger"
onClick={() => {
const updatedRegulators = (formData.regulators || []).filter((_, i) => i !== index)
setFormData({ ...formData, regulators: updatedRegulators })
}}
>
Remove
</Button>
</div>
)
})}
<Button
type="button"
variant="secondary"
onClick={() => {
const newAssociation: ModuleComponentRegulatorAssociation = {
regulatorId: '',
percentage: 0,
}
setFormData({
...formData,
regulators: [...(formData.regulators || []), newAssociation],
})
}}
>
Add Regulator
</Button>
{(formData.regulators || []).length > 0 && (
<div className="regulators-summary">
<strong>
Total: {(formData.regulators || []).reduce((sum, a) => sum + (a.percentage || 0), 0).toFixed(1)}%
</strong>
{errors.regulators && <span className="error-text">{errors.regulators}</span>}
</div>
)}
</div>
</div>
{/* Associated Services Section */}
<div className="form-section">
<h3 className="form-section-title">Associated Services</h3>
<p className="form-help-text">
Add services to this module component. Services can be used for business plan calculations.
</p>
<div className="services-list">
{(formData.services || []).map((association, index) => {
const service = services.find((s) => s.id === association.serviceId)
return (
<div key={index} className="service-association-item">
<Select
label="Service"
value={association.serviceId || ''}
onChange={(e) => {
const updatedServices = [...(formData.services || [])]
updatedServices[index] = {
...updatedServices[index],
serviceId: e.target.value,
}
setFormData({ ...formData, services: updatedServices })
}}
options={[
{ value: '', label: 'Select a service' },
...services.map((s) => ({
value: s.id,
label: `${s.name} (${s.type})`,
})).filter(
(opt) =>
opt.value === association.serviceId ||
!(formData.services || []).some((a) => a.serviceId === opt.value)
),
]}
/>
<Input
label="Notes (optional)"
value={association.notes || ''}
onChange={(e) => {
const updatedServices = [...(formData.services || [])]
updatedServices[index] = {
...updatedServices[index],
notes: e.target.value || undefined,
}
setFormData({ ...formData, services: updatedServices })
}}
helpText={service ? `Type: ${service.type}` : undefined}
/>
<Button
type="button"
variant="danger"
onClick={() => {
const updatedServices = (formData.services || []).filter((_, i) => i !== index)
setFormData({ ...formData, services: updatedServices })
}}
>
Remove
</Button>
</div>
)
})}
<Button
type="button"
variant="secondary"
onClick={() => {
const newAssociation: ModuleComponentServiceAssociation = {
serviceId: '',
}
setFormData({
...formData,
services: [...(formData.services || []), newAssociation],
})
}}
>
Add Service
</Button>
</div>
</div>
{/* Costs Section */}
<div className="form-section">
<h3 className="form-section-title">Costs (Annual)</h3>

View File

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

View File

@ -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<Record<string, string>>({})
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 <span style={{ color: 'var(--text-secondary)' }}>None</span>
}
return (
<div>
{site.transporters.map((assoc, idx) => {
const transporter = transporters.find((t) => t.id === assoc.transporterId)
return (
<Badge key={idx} variant={assoc.isPrimary ? 'success' : 'info'} style={{ marginRight: '4px' }}>
{transporter?.name || 'Unknown'} {assoc.isPrimary && '(Primary)'}
</Badge>
)
})}
</div>
)
},
},
{
key: 'actions',
header: 'Actions',
@ -211,6 +236,110 @@ export default function TreatmentSitesPage() {
</div>
</div>
{/* Associated Transporters Section */}
<div className="form-section">
<h3 className="form-section-title">Associated Transporters</h3>
<p className="form-help-text">
Add transporters for this treatment site. Mark one as primary if needed.
</p>
<div className="transporters-list">
{(formData.transporters || []).map((association, index) => {
const transporter = transporters.find((t) => t.id === association.transporterId)
return (
<div key={index} className="transporter-association-item">
<Select
label="Transporter"
value={association.transporterId || ''}
onChange={(e) => {
const updatedTransporters = [...(formData.transporters || [])]
updatedTransporters[index] = {
...updatedTransporters[index],
transporterId: e.target.value,
}
setFormData({ ...formData, transporters: updatedTransporters })
}}
options={[
{ value: '', label: 'Select a transporter' },
...transporters.map((t) => ({
value: t.id,
label: `${t.name} (${t.type})`,
})).filter(
(opt) =>
opt.value === association.transporterId ||
!(formData.transporters || []).some((a) => a.transporterId === opt.value)
),
]}
/>
<div className="form-checkbox">
<label>
<input
type="checkbox"
checked={association.isPrimary || false}
onChange={(e) => {
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 })
}}
/>
<span>Primary</span>
</label>
</div>
<Input
label="Notes (optional)"
value={association.notes || ''}
onChange={(e) => {
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}
/>
<Button
type="button"
variant="danger"
onClick={() => {
const updatedTransporters = (formData.transporters || []).filter((_, i) => i !== index)
setFormData({ ...formData, transporters: updatedTransporters })
}}
>
Remove
</Button>
</div>
)
})}
<Button
type="button"
variant="secondary"
onClick={() => {
const newAssociation: SiteTransporterAssociation = {
transporterId: '',
isPrimary: false,
}
setFormData({
...formData,
transporters: [...(formData.transporters || []), newAssociation],
})
}}
>
Add Transporter
</Button>
</div>
</div>
<div className="form-actions">
<Button type="submit" variant="primary">
{editingId ? 'Update' : 'Add'} Treatment Site

View File

@ -33,3 +33,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);
}

View File

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useStorage } from '@/hooks/useStorage'
import { WasteSite, SiteStatus } from '@/types'
import { WasteSite, SiteStatus, SiteTransporterAssociation } from '@/types'
import Card from '@/components/base/Card'
import Button from '@/components/base/Button'
import Input from '@/components/base/Input'
@ -27,11 +27,13 @@ export default function WasteSitesPage() {
},
collectionType: '',
distance: 0,
transporters: [],
})
const [errors, setErrors] = useState<Record<string, string>>({})
const sites = data?.wasteSites || []
const wastes = data?.wastes || []
const transporters = data?.transporters || []
const statusOptions = [
{ value: 'toBeApproached', label: 'To be approached' },
@ -74,6 +76,7 @@ export default function WasteSitesPage() {
contact: formData.contact!,
collectionType: formData.collectionType!,
distance: formData.distance!,
transporters: formData.transporters || [],
createdAt: editingId ? sites.find((s) => s.id === editingId)?.createdAt || new Date().toISOString() : new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
@ -102,6 +105,7 @@ export default function WasteSitesPage() {
},
collectionType: '',
distance: 0,
transporters: [],
})
setEditingId(null)
setErrors({})
@ -162,6 +166,27 @@ export default function WasteSitesPage() {
header: 'Distance (km)',
render: (site: WasteSite) => site.distance,
},
{
key: 'transporters',
header: 'Transporters',
render: (site: WasteSite) => {
if (!site.transporters || site.transporters.length === 0) {
return <span style={{ color: 'var(--text-secondary)' }}>None</span>
}
return (
<div>
{site.transporters.map((assoc, idx) => {
const transporter = transporters.find((t) => t.id === assoc.transporterId)
return (
<Badge key={idx} variant={assoc.isPrimary ? 'success' : 'info'} style={{ marginRight: '4px' }}>
{transporter?.name || 'Unknown'} {assoc.isPrimary && '(Primary)'}
</Badge>
)
})}
</div>
)
},
},
{
key: 'actions',
header: 'Actions',
@ -283,6 +308,110 @@ export default function WasteSitesPage() {
error={errors.distance}
/>
{/* Associated Transporters Section */}
<div className="form-section">
<h3 className="form-section-title">Associated Transporters</h3>
<p className="form-help-text">
Add transporters for this waste site. Mark one as primary if needed.
</p>
<div className="transporters-list">
{(formData.transporters || []).map((association, index) => {
const transporter = transporters.find((t) => t.id === association.transporterId)
return (
<div key={index} className="transporter-association-item">
<Select
label="Transporter"
value={association.transporterId || ''}
onChange={(e) => {
const updatedTransporters = [...(formData.transporters || [])]
updatedTransporters[index] = {
...updatedTransporters[index],
transporterId: e.target.value,
}
setFormData({ ...formData, transporters: updatedTransporters })
}}
options={[
{ value: '', label: 'Select a transporter' },
...transporters.map((t) => ({
value: t.id,
label: `${t.name} (${t.type})`,
})).filter(
(opt) =>
opt.value === association.transporterId ||
!(formData.transporters || []).some((a) => a.transporterId === opt.value)
),
]}
/>
<div className="form-checkbox">
<label>
<input
type="checkbox"
checked={association.isPrimary || false}
onChange={(e) => {
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 })
}}
/>
<span>Primary</span>
</label>
</div>
<Input
label="Notes (optional)"
value={association.notes || ''}
onChange={(e) => {
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}
/>
<Button
type="button"
variant="danger"
onClick={() => {
const updatedTransporters = (formData.transporters || []).filter((_, i) => i !== index)
setFormData({ ...formData, transporters: updatedTransporters })
}}
>
Remove
</Button>
</div>
)
})}
<Button
type="button"
variant="secondary"
onClick={() => {
const newAssociation: SiteTransporterAssociation = {
transporterId: '',
isPrimary: false,
}
setFormData({
...formData,
transporters: [...(formData.transporters || []), newAssociation],
})
}}
>
Add Transporter
</Button>
</div>
</div>
<div className="form-actions">
<Button type="submit" variant="primary">
{editingId ? 'Update' : 'Add'} Waste Site

View File

@ -39,6 +39,26 @@ export interface WasteRegulatorAssociation {
percentage: number // Percentage of total volume (0-100)
}
// Module Component Regulator Association
export interface ModuleComponentRegulatorAssociation {
regulatorId: string // Reference to NaturalRegulator
percentage: number // Percentage of total volume (0-100)
dosage?: number // Optional specific dosage (kg/t, L/t, or %)
}
// Site Transporter Association
export interface SiteTransporterAssociation {
transporterId: string // Reference to Transporter
isPrimary?: boolean // Whether this is the primary transporter for the site
notes?: string // Optional notes about this transporter association
}
// Module Component Service Association
export interface ModuleComponentServiceAssociation {
serviceId: string // Reference to Service
notes?: string // Optional notes about this service association
}
// Waste
export interface Waste extends BaseEntity {
name: string
@ -196,6 +216,7 @@ export interface TreatmentSite extends BaseEntity {
availableGroundSurface: number // m²
monthlyTemperatures: number[] // 12 values, °C
subscribedServices: string[] // service IDs
transporters?: SiteTransporterAssociation[] // Associated transporters
}
// Waste Site
@ -216,6 +237,7 @@ export interface WasteSite extends BaseEntity {
}
collectionType: string
distance: number // km
transporters?: SiteTransporterAssociation[] // Associated transporters
}
// Investor
@ -445,6 +467,12 @@ export interface ModuleComponent extends BaseEntity {
// Lifetime
lifetime?: number // years (Durée de vie)
// Associated regulators
regulators?: ModuleComponentRegulatorAssociation[] // Associated regulators with volume percentages
// Associated services
services?: ModuleComponentServiceAssociation[] // Associated services
}
// Ecosystem Regulator Association