8 mars
This commit is contained in:
140
docs/plans/2026-03-08-intrants-fabrications-design.md
Normal file
140
docs/plans/2026-03-08-intrants-fabrications-design.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Intrants & Fabrications — Design
|
||||
|
||||
> **Pour Claude :** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Gérer les achats d'intrants (terreau, engrais, traitements) et les fabrications maison (compost, décoctions, purins) dans une vue unique avec deux onglets, liés aux jardins/plantations/tâches existants.
|
||||
|
||||
**Architecture:** Option B — deux tables distinctes (`achat_intrant` + `fabrication`), une vue `IntratsView.vue` avec onglets, deux routers FastAPI. Les ingrédients d'une fabrication sont stockés en JSON.
|
||||
|
||||
**Tech Stack:** FastAPI + SQLModel + SQLite (backend), Vue 3 + Pinia + Tailwind Gruvbox (frontend)
|
||||
|
||||
---
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Table `achat_intrant`
|
||||
|
||||
| Champ | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | |
|
||||
| categorie | TEXT | `terreau` \| `engrais` \| `traitement` \| `autre` |
|
||||
| nom | TEXT | Nom du produit |
|
||||
| marque | TEXT | Fabricant / marque |
|
||||
| boutique_nom | TEXT | Gamm Vert, Lidl, Amazon… |
|
||||
| boutique_url | TEXT | URL fiche produit |
|
||||
| prix | REAL | En € |
|
||||
| poids | TEXT | Ex: "20L", "1kg", "500ml" |
|
||||
| date_achat | TEXT | ISO date |
|
||||
| dluo | TEXT | ISO date limite d'utilisation |
|
||||
| notes | TEXT | Observations libres |
|
||||
| jardin_id | INTEGER FK → garden | Optionnel |
|
||||
| plantation_id | INTEGER FK → planting | Optionnel |
|
||||
| tache_id | INTEGER FK → task | Optionnel |
|
||||
| created_at | TEXT | ISO datetime |
|
||||
|
||||
### Table `fabrication`
|
||||
|
||||
| Champ | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | |
|
||||
| type | TEXT | `compost` \| `decoction` \| `purin` \| `autre` |
|
||||
| nom | TEXT | Ex: "Purin d'ortie mai 2026" |
|
||||
| ingredients | TEXT | JSON : `[{"nom": "ortie", "quantite": "1kg"}, ...]` |
|
||||
| date_debut | TEXT | ISO date |
|
||||
| date_fin_prevue | TEXT | ISO date |
|
||||
| statut | TEXT | `en_cours` \| `pret` \| `utilise` \| `echec` |
|
||||
| quantite_produite | TEXT | Ex: "8L", "50kg" |
|
||||
| notes | TEXT | Recette libre, observations |
|
||||
| jardin_id | INTEGER FK → garden | Optionnel |
|
||||
| plantation_id | INTEGER FK → planting | Optionnel |
|
||||
| tache_id | INTEGER FK → task | Optionnel |
|
||||
| created_at | TEXT | ISO datetime |
|
||||
|
||||
---
|
||||
|
||||
## API REST
|
||||
|
||||
```
|
||||
GET /api/achats → liste des achats (filtre: categorie, jardin_id)
|
||||
POST /api/achats → créer un achat
|
||||
GET /api/achats/{id} → détail
|
||||
PUT /api/achats/{id} → modifier
|
||||
DEL /api/achats/{id} → supprimer
|
||||
|
||||
GET /api/fabrications → liste (filtre: type, statut, jardin_id)
|
||||
POST /api/fabrications → créer
|
||||
GET /api/fabrications/{id} → détail
|
||||
PUT /api/fabrications/{id} → modifier
|
||||
DEL /api/fabrications/{id} → supprimer
|
||||
PATCH /api/fabrications/{id}/statut → changer statut rapidement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Nouveau fichier : `frontend/src/views/IntratsView.vue`
|
||||
|
||||
**Nouvel item sidebar :** 🧪 Intrants (entre Outils et Réglages)
|
||||
|
||||
**Structure :**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 🧪 Intrants [🛒 Achats] [🌿 Fabrications] │
|
||||
│ │
|
||||
│ Onglet Achats : │
|
||||
│ • Filtres : Catégorie (terreau/engrais/traitement) │
|
||||
│ • Grille de cartes : nom, marque, prix, poids, │
|
||||
│ enseigne, DLUO (rouge si expirée) │
|
||||
│ • Bouton "+ Ajouter un achat" │
|
||||
│ • Popup détail + formulaire ajout/édition │
|
||||
│ │
|
||||
│ Onglet Fabrications : │
|
||||
│ • Filtres : Type + Statut │
|
||||
│ • Cartes : nom, type, statut (badge coloré), │
|
||||
│ date fin prévue, ingrédients résumés │
|
||||
│ • Boutons rapides : ✓ Prêt / ✗ Échec │
|
||||
│ • Popup détail avec liste ingrédients éditable │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Couleurs statut fabrication :**
|
||||
- `en_cours` → orange
|
||||
- `pret` → vert
|
||||
- `utilise` → gris text-muted
|
||||
- `echec` → rouge
|
||||
|
||||
### Nouveaux fichiers API : `frontend/src/api/achats.ts` + `frontend/src/api/fabrications.ts`
|
||||
|
||||
### Nouveaux stores Pinia : `frontend/src/stores/achats.ts` + `frontend/src/stores/fabrications.ts`
|
||||
|
||||
### Mise à jour `App.vue` : ajouter route `/intrants` dans la sidebar
|
||||
|
||||
---
|
||||
|
||||
## Migration BDD
|
||||
|
||||
Ajouter dans `backend/app/migrate.py` :
|
||||
- Section `"achat_intrant"` avec toutes ses colonnes
|
||||
- Section `"fabrication"` avec toutes ses colonnes
|
||||
|
||||
Les tables seront créées au démarrage via SQLModel metadata si absentes, puis migrées par `run_migrations()`.
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à créer / modifier
|
||||
|
||||
| Fichier | Action |
|
||||
|---|---|
|
||||
| `backend/app/models/intrant.py` | Créer : AchatIntrant + Fabrication SQLModel |
|
||||
| `backend/app/routers/achats.py` | Créer : CRUD AchatIntrant |
|
||||
| `backend/app/routers/fabrications.py` | Créer : CRUD Fabrication + PATCH statut |
|
||||
| `backend/app/main.py` | Modifier : import + include routers |
|
||||
| `backend/app/migrate.py` | Modifier : ajouter sections achat_intrant + fabrication |
|
||||
| `frontend/src/api/achats.ts` | Créer |
|
||||
| `frontend/src/api/fabrications.ts` | Créer |
|
||||
| `frontend/src/stores/achats.ts` | Créer |
|
||||
| `frontend/src/stores/fabrications.ts` | Créer |
|
||||
| `frontend/src/views/IntratsView.vue` | Créer |
|
||||
| `frontend/src/router/index.ts` | Modifier : route /intrants |
|
||||
| `frontend/src/App.vue` | Modifier : sidebar item 🧪 Intrants |
|
||||
1326
docs/plans/2026-03-08-intrants-fabrications.md
Normal file
1326
docs/plans/2026-03-08-intrants-fabrications.md
Normal file
File diff suppressed because it is too large
Load Diff
233
docs/plans/2026-03-08-plant-associations.md
Normal file
233
docs/plans/2026-03-08-plant-associations.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Plant Associations Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Ajouter des associations favorables/défavorables (plantes amies/ennemies) à chaque plante, éditables depuis la popup d'édition.
|
||||
|
||||
**Architecture:** Deux colonnes JSON (`associations_favorables`, `associations_defavorables`) dans le modèle `Plant` (List[str] de noms communs). Migration via migrate.py. UI tag-based avec autocomplete et validation croisée dans PlantesView.vue.
|
||||
|
||||
**Tech Stack:** FastAPI + SQLModel + SQLAlchemy JSON column (backend) · Vue 3 + TypeScript (frontend)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — modèle Plant
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/plant.py`
|
||||
|
||||
**Step 1: Ajouter les imports nécessaires**
|
||||
|
||||
```python
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import JSON as SA_JSON
|
||||
```
|
||||
|
||||
**Step 2: Ajouter les 2 champs dans la classe `Plant`**
|
||||
|
||||
Après le champ `notes`, avant `created_at` :
|
||||
|
||||
```python
|
||||
associations_favorables: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
sa_column=Column("associations_favorables", SA_JSON, nullable=True),
|
||||
)
|
||||
associations_defavorables: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
sa_column=Column("associations_defavorables", SA_JSON, nullable=True),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/migrate.py`
|
||||
|
||||
**Step 1: Ajouter les 2 colonnes dans `EXPECTED_COLUMNS["plant"]`**
|
||||
|
||||
```python
|
||||
"plant": [
|
||||
("categorie", "TEXT", None),
|
||||
("hauteur_cm", "INTEGER", None),
|
||||
("maladies_courantes", "TEXT", None),
|
||||
("astuces_culture", "TEXT", None),
|
||||
("url_reference", "TEXT", None),
|
||||
("associations_favorables", "TEXT", None), # JSON list[str]
|
||||
("associations_defavorables", "TEXT", None), # JSON list[str]
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — interface TypeScript
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/plants.ts`
|
||||
|
||||
**Step 1: Ajouter les 2 champs à l'interface `Plant`**
|
||||
|
||||
```typescript
|
||||
associations_favorables?: string[]
|
||||
associations_defavorables?: string[]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — PlantesView.vue (formulaire + détail)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/PlantesView.vue`
|
||||
|
||||
**Step 1: Étendre `form` reactive**
|
||||
|
||||
Dans `const form = reactive({...})` ajouter :
|
||||
```typescript
|
||||
associations_favorables: [] as string[],
|
||||
associations_defavorables: [] as string[],
|
||||
```
|
||||
|
||||
**Step 2: Ajouter ref pour l'autocomplete**
|
||||
|
||||
```typescript
|
||||
const assocInput = reactive({ fav: '', def: '' })
|
||||
```
|
||||
|
||||
**Step 3: Computed — noms de plantes disponibles pour l'autocomplete**
|
||||
|
||||
```typescript
|
||||
const allPlantNames = computed(() =>
|
||||
plantsStore.plants
|
||||
.map(p => p.nom_commun)
|
||||
.filter(n => n && n !== form.nom_commun)
|
||||
.sort()
|
||||
)
|
||||
|
||||
function filteredAssocSuggestions(type: 'fav' | 'def') {
|
||||
const query = assocInput[type].toLowerCase()
|
||||
const excluded = type === 'fav'
|
||||
? new Set([...form.associations_favorables, ...form.associations_defavorables])
|
||||
: new Set([...form.associations_defavorables, ...form.associations_favorables])
|
||||
return allPlantNames.value
|
||||
.filter(n => !excluded.has(n) && n.toLowerCase().includes(query))
|
||||
.slice(0, 8)
|
||||
}
|
||||
|
||||
function addAssoc(type: 'fav' | 'def', name: string) {
|
||||
const list = type === 'fav' ? form.associations_favorables : form.associations_defavorables
|
||||
const other = type === 'fav' ? form.associations_defavorables : form.associations_favorables
|
||||
if (!name.trim() || list.includes(name) || other.includes(name)) return
|
||||
list.push(name)
|
||||
assocInput[type] = ''
|
||||
}
|
||||
|
||||
function removeAssoc(type: 'fav' | 'def', name: string) {
|
||||
const list = type === 'fav' ? form.associations_favorables : form.associations_defavorables
|
||||
const idx = list.indexOf(name)
|
||||
if (idx !== -1) list.splice(idx, 1)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: `startEdit` — peupler les nouvelles listes**
|
||||
|
||||
```typescript
|
||||
associations_favorables: [...(p.associations_favorables ?? [])],
|
||||
associations_defavorables: [...(p.associations_defavorables ?? [])],
|
||||
```
|
||||
|
||||
**Step 5: `submitPlant` — inclure les listes dans le payload**
|
||||
|
||||
Le spread `{ ...form }` les inclut automatiquement — rien à changer.
|
||||
|
||||
**Step 6: Ajouter le bloc UI dans le formulaire (pleine largeur, après les 2 colonnes existantes)**
|
||||
|
||||
```html
|
||||
<!-- Associations — pleine largeur -->
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Favorables -->
|
||||
<div>
|
||||
<label class="text-[10px] font-black text-green uppercase tracking-widest block mb-2">
|
||||
🤝 Associations favorables
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-2 min-h-[28px]">
|
||||
<span v-for="n in form.associations_favorables" :key="n"
|
||||
class="flex items-center gap-1 bg-green/10 border border-green/40 text-green text-[11px] px-2 py-0.5 rounded-full">
|
||||
{{ n }}
|
||||
<button type="button" @click="removeAssoc('fav', n)" class="hover:text-red leading-none">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input v-model="assocInput.fav"
|
||||
placeholder="Nom commun d'une plante..."
|
||||
class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 text-text text-sm focus:border-green outline-none"
|
||||
@keydown.enter.prevent="addAssoc('fav', assocInput.fav)" />
|
||||
<ul v-if="assocInput.fav && filteredAssocSuggestions('fav').length"
|
||||
class="absolute z-10 mt-1 w-full bg-bg-hard border border-bg-soft rounded-xl shadow-lg overflow-hidden max-h-40 overflow-y-auto">
|
||||
<li v-for="s in filteredAssocSuggestions('fav')" :key="s"
|
||||
@click="addAssoc('fav', s)"
|
||||
class="px-3 py-2 text-sm text-text hover:bg-green/10 hover:text-green cursor-pointer">
|
||||
{{ s }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Défavorables -->
|
||||
<div>
|
||||
<label class="text-[10px] font-black text-red uppercase tracking-widest block mb-2">
|
||||
⚡ Associations défavorables
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-2 min-h-[28px]">
|
||||
<span v-for="n in form.associations_defavorables" :key="n"
|
||||
class="flex items-center gap-1 bg-red/10 border border-red/40 text-red text-[11px] px-2 py-0.5 rounded-full">
|
||||
{{ n }}
|
||||
<button type="button" @click="removeAssoc('def', n)" class="hover:text-red leading-none">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input v-model="assocInput.def"
|
||||
placeholder="Nom commun d'une plante..."
|
||||
class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 text-text text-sm focus:border-red outline-none"
|
||||
@keydown.enter.prevent="addAssoc('def', assocInput.def)" />
|
||||
<ul v-if="assocInput.def && filteredAssocSuggestions('def').length"
|
||||
class="absolute z-10 mt-1 w-full bg-bg-hard border border-bg-soft rounded-xl shadow-lg overflow-hidden max-h-40 overflow-y-auto">
|
||||
<li v-for="s in filteredAssocSuggestions('def')" :key="s"
|
||||
@click="addAssoc('def', s)"
|
||||
class="px-3 py-2 text-sm text-text hover:bg-red/10 hover:text-red cursor-pointer">
|
||||
{{ s }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 7: Ajouter le bloc lecture dans la modale détail (après la section Notes)**
|
||||
|
||||
```html
|
||||
<!-- Associations -->
|
||||
<div v-if="detailPlant.associations_favorables?.length || detailPlant.associations_defavorables?.length" class="space-y-3">
|
||||
<h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Associations</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div v-if="detailPlant.associations_favorables?.length">
|
||||
<div class="text-[10px] font-black text-green uppercase mb-1.5">🤝 Favorables</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span v-for="n in detailPlant.associations_favorables" :key="n"
|
||||
class="bg-green/10 border border-green/40 text-green text-[11px] px-2 py-0.5 rounded-full">
|
||||
{{ n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detailPlant.associations_defavorables?.length">
|
||||
<div class="text-[10px] font-black text-red uppercase mb-1.5">⚡ À éviter</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span v-for="n in detailPlant.associations_defavorables" :key="n"
|
||||
class="bg-red/10 border border-red/40 text-red text-[11px] px-2 py-0.5 rounded-full">
|
||||
{{ n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
Reference in New Issue
Block a user