Files
jardin/docs/plans/2026-03-08-intrants-fabrications.md
2026-03-08 10:04:14 +01:00

1327 lines
54 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Intrants & Fabrications — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Ajouter la gestion des achats d'intrants (terreau, engrais, traitements) et des fabrications maison (compost, décoctions, purins) dans une vue unique avec deux onglets, liée aux jardins/plantations/tâches.
**Architecture:** Deux tables SQLite (`achat_intrant` + `fabrication`), deux routers FastAPI, une vue Vue 3 `IntratsView.vue` avec onglets. Les ingrédients de fabrication sont stockés en JSON. Liaisons optionnelles vers garden/planting/task.
**Tech Stack:** FastAPI + SQLModel + SQLite · Vue 3 + Pinia + Tailwind CSS Gruvbox Dark
---
### Task 1 : Modèles SQLModel backend
**Files:**
- Create: `backend/app/models/intrant.py`
- Modify: `backend/app/models/__init__.py`
**Step 1: Créer le fichier modèle**
```python
# backend/app/models/intrant.py
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy import Column
from sqlalchemy import JSON as SA_JSON
from sqlmodel import Field, SQLModel
class AchatIntrant(SQLModel, table=True):
__tablename__ = "achat_intrant"
id: Optional[int] = Field(default=None, primary_key=True)
categorie: str # terreau | engrais | traitement | autre
nom: str
marque: Optional[str] = None
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
prix: Optional[float] = None
poids: Optional[str] = None # "20L", "1kg", "500ml"
date_achat: Optional[str] = None # ISO date
dluo: Optional[str] = None # ISO date
notes: Optional[str] = None
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Ingredient(SQLModel):
"""Modèle pour un ingrédient de fabrication (non persisté seul)."""
nom: str
quantite: str # "1kg", "10L"
class Fabrication(SQLModel, table=True):
__tablename__ = "fabrication"
id: Optional[int] = Field(default=None, primary_key=True)
type: str # compost | decoction | purin | autre
nom: str
ingredients: Optional[List[dict]] = Field(
default=None,
sa_column=Column("ingredients", SA_JSON, nullable=True),
)
date_debut: Optional[str] = None # ISO date
date_fin_prevue: Optional[str] = None # ISO date
statut: str = "en_cours" # en_cours | pret | utilise | echec
quantite_produite: Optional[str] = None # "8L", "50kg"
notes: Optional[str] = None
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class FabricationStatutUpdate(SQLModel):
statut: str
```
**Step 2: Enregistrer dans `__init__.py`**
Ajouter à la fin de `backend/app/models/__init__.py` :
```python
from app.models.intrant import AchatIntrant, Fabrication # noqa
```
**Step 3: Vérifier que SQLModel crée bien les tables**
```bash
cd backend
python3 -c "from app.models.intrant import AchatIntrant, Fabrication; print('OK')"
```
Expected: `OK`
**Step 4: Commit**
```bash
git add backend/app/models/intrant.py backend/app/models/__init__.py
git commit -m "feat(intrants): add AchatIntrant + Fabrication SQLModel"
```
---
### Task 2 : Migration BDD
**Files:**
- Modify: `backend/app/migrate.py`
**Step 1: Ajouter les sections dans `EXPECTED_COLUMNS`**
Dans `backend/app/migrate.py`, après la section `"astuce"`, ajouter :
```python
"achat_intrant": [
("categorie", "TEXT", None),
("nom", "TEXT", None),
("marque", "TEXT", None),
("boutique_nom", "TEXT", None),
("boutique_url", "TEXT", None),
("prix", "REAL", None),
("poids", "TEXT", None),
("date_achat", "TEXT", None),
("dluo", "TEXT", None),
("notes", "TEXT", None),
("jardin_id", "INTEGER", None),
("plantation_id", "INTEGER", None),
("tache_id", "INTEGER", None),
],
"fabrication": [
("type", "TEXT", None),
("nom", "TEXT", None),
("ingredients", "TEXT", None),
("date_debut", "TEXT", None),
("date_fin_prevue", "TEXT", None),
("statut", "TEXT", "'en_cours'"),
("quantite_produite", "TEXT", None),
("notes", "TEXT", None),
("jardin_id", "INTEGER", None),
("plantation_id", "INTEGER", None),
("tache_id", "INTEGER", None),
],
```
**Step 2: Appliquer la migration manuellement**
```bash
python3 - <<'EOF'
import sqlite3
conn = sqlite3.connect('data/jardin.db')
# Créer achat_intrant si absente
conn.execute("""
CREATE TABLE IF NOT EXISTS achat_intrant (
id INTEGER PRIMARY KEY AUTOINCREMENT,
categorie TEXT NOT NULL,
nom TEXT NOT NULL,
marque TEXT,
boutique_nom TEXT,
boutique_url TEXT,
prix REAL,
poids TEXT,
date_achat TEXT,
dluo TEXT,
notes TEXT,
jardin_id INTEGER REFERENCES garden(id),
plantation_id INTEGER REFERENCES planting(id),
tache_id INTEGER REFERENCES task(id),
created_at TEXT DEFAULT (datetime('now'))
)
""")
# Créer fabrication si absente
conn.execute("""
CREATE TABLE IF NOT EXISTS fabrication (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
nom TEXT NOT NULL,
ingredients TEXT,
date_debut TEXT,
date_fin_prevue TEXT,
statut TEXT DEFAULT 'en_cours',
quantite_produite TEXT,
notes TEXT,
jardin_id INTEGER REFERENCES garden(id),
plantation_id INTEGER REFERENCES planting(id),
tache_id INTEGER REFERENCES task(id),
created_at TEXT DEFAULT (datetime('now'))
)
""")
conn.commit()
conn.close()
print("Tables créées")
EOF
```
Expected: `Tables créées`
**Step 3: Commit**
```bash
git add backend/app/migrate.py
git commit -m "feat(intrants): add migration for achat_intrant + fabrication tables"
```
---
### Task 3 : Router achats (CRUD)
**Files:**
- Create: `backend/app/routers/achats.py`
**Step 1: Créer le router**
```python
# backend/app/routers/achats.py
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.intrant import AchatIntrant
router = APIRouter(tags=["intrants"])
@router.get("/achats", response_model=List[AchatIntrant])
def list_achats(
categorie: Optional[str] = Query(None),
jardin_id: Optional[int] = Query(None),
session: Session = Depends(get_session),
):
q = select(AchatIntrant)
if categorie:
q = q.where(AchatIntrant.categorie == categorie)
if jardin_id:
q = q.where(AchatIntrant.jardin_id == jardin_id)
return session.exec(q.order_by(AchatIntrant.created_at.desc())).all()
@router.post("/achats", response_model=AchatIntrant, status_code=status.HTTP_201_CREATED)
def create_achat(a: AchatIntrant, session: Session = Depends(get_session)):
session.add(a)
session.commit()
session.refresh(a)
return a
@router.get("/achats/{id}", response_model=AchatIntrant)
def get_achat(id: int, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
return a
@router.put("/achats/{id}", response_model=AchatIntrant)
def update_achat(id: int, data: AchatIntrant, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(a, k, v)
session.add(a)
session.commit()
session.refresh(a)
return a
@router.delete("/achats/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_achat(id: int, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
session.delete(a)
session.commit()
```
**Step 2: Commit**
```bash
git add backend/app/routers/achats.py
git commit -m "feat(intrants): CRUD router for achats"
```
---
### Task 4 : Router fabrications (CRUD + PATCH statut)
**Files:**
- Create: `backend/app/routers/fabrications.py`
**Step 1: Créer le router**
```python
# backend/app/routers/fabrications.py
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.intrant import Fabrication, FabricationStatutUpdate
router = APIRouter(tags=["intrants"])
@router.get("/fabrications", response_model=List[Fabrication])
def list_fabrications(
type: Optional[str] = Query(None),
statut: Optional[str] = Query(None),
jardin_id: Optional[int] = Query(None),
session: Session = Depends(get_session),
):
q = select(Fabrication)
if type:
q = q.where(Fabrication.type == type)
if statut:
q = q.where(Fabrication.statut == statut)
if jardin_id:
q = q.where(Fabrication.jardin_id == jardin_id)
return session.exec(q.order_by(Fabrication.created_at.desc())).all()
@router.post("/fabrications", response_model=Fabrication, status_code=status.HTTP_201_CREATED)
def create_fabrication(f: Fabrication, session: Session = Depends(get_session)):
session.add(f)
session.commit()
session.refresh(f)
return f
@router.get("/fabrications/{id}", response_model=Fabrication)
def get_fabrication(id: int, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
return f
@router.put("/fabrications/{id}", response_model=Fabrication)
def update_fabrication(id: int, data: Fabrication, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(f, k, v)
session.add(f)
session.commit()
session.refresh(f)
return f
@router.patch("/fabrications/{id}/statut", response_model=Fabrication)
def update_statut(id: int, data: FabricationStatutUpdate, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
valid = {"en_cours", "pret", "utilise", "echec"}
if data.statut not in valid:
raise HTTPException(400, f"Statut invalide. Valeurs: {valid}")
f.statut = data.statut
session.add(f)
session.commit()
session.refresh(f)
return f
@router.delete("/fabrications/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_fabrication(id: int, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
session.delete(f)
session.commit()
```
**Step 2: Commit**
```bash
git add backend/app/routers/fabrications.py
git commit -m "feat(intrants): CRUD + statut router for fabrications"
```
---
### Task 5 : Enregistrer les routers dans main.py
**Files:**
- Modify: `backend/app/main.py`
**Step 1: Ajouter l'import**
Dans le bloc `from app.routers import (`, ajouter après `identify,` :
```python
achats,
fabrications,
```
**Step 2: Ajouter les includes**
Après `app.include_router(identify.router, prefix="/api")`, ajouter :
```python
app.include_router(achats.router, prefix="/api")
app.include_router(fabrications.router, prefix="/api")
```
**Step 3: Tester l'API**
```bash
cd backend && uvicorn app.main:app --reload --port 8060
# Dans un autre terminal:
curl http://localhost:8060/api/achats
# Expected: []
curl http://localhost:8060/api/fabrications
# Expected: []
```
**Step 4: Commit**
```bash
git add backend/app/main.py
git commit -m "feat(intrants): register achats + fabrications routers"
```
---
### Task 6 : API frontend (achats.ts + fabrications.ts)
**Files:**
- Create: `frontend/src/api/achats.ts`
- Create: `frontend/src/api/fabrications.ts`
**Step 1: Créer `achats.ts`**
```typescript
// frontend/src/api/achats.ts
import client from './client'
export interface AchatIntrant {
id?: number
categorie: string // terreau | engrais | traitement | autre
nom: string
marque?: string
boutique_nom?: string
boutique_url?: string
prix?: number
poids?: string
date_achat?: string
dluo?: string
notes?: string
jardin_id?: number
plantation_id?: number
tache_id?: number
}
export const achatsApi = {
list: (params?: { categorie?: string; jardin_id?: number }) =>
client.get<AchatIntrant[]>('/api/achats', { params }).then(r => r.data),
get: (id: number) => client.get<AchatIntrant>(`/api/achats/${id}`).then(r => r.data),
create: (a: Partial<AchatIntrant>) => client.post<AchatIntrant>('/api/achats', a).then(r => r.data),
update: (id: number, a: Partial<AchatIntrant>) => client.put<AchatIntrant>(`/api/achats/${id}`, a).then(r => r.data),
delete: (id: number) => client.delete(`/api/achats/${id}`),
}
```
**Step 2: Créer `fabrications.ts`**
```typescript
// frontend/src/api/fabrications.ts
import client from './client'
export interface Ingredient {
nom: string
quantite: string
}
export interface Fabrication {
id?: number
type: string // compost | decoction | purin | autre
nom: string
ingredients?: Ingredient[]
date_debut?: string
date_fin_prevue?: string
statut?: string // en_cours | pret | utilise | echec
quantite_produite?: string
notes?: string
jardin_id?: number
plantation_id?: number
tache_id?: number
}
export const fabricationsApi = {
list: (params?: { type?: string; statut?: string; jardin_id?: number }) =>
client.get<Fabrication[]>('/api/fabrications', { params }).then(r => r.data),
get: (id: number) => client.get<Fabrication>(`/api/fabrications/${id}`).then(r => r.data),
create: (f: Partial<Fabrication>) => client.post<Fabrication>('/api/fabrications', f).then(r => r.data),
update: (id: number, f: Partial<Fabrication>) => client.put<Fabrication>(`/api/fabrications/${id}`, f).then(r => r.data),
updateStatut: (id: number, statut: string) => client.patch<Fabrication>(`/api/fabrications/${id}/statut`, { statut }).then(r => r.data),
delete: (id: number) => client.delete(`/api/fabrications/${id}`),
}
```
**Step 3: Commit**
```bash
git add frontend/src/api/achats.ts frontend/src/api/fabrications.ts
git commit -m "feat(intrants): frontend API clients for achats + fabrications"
```
---
### Task 7 : Stores Pinia
**Files:**
- Create: `frontend/src/stores/achats.ts`
- Create: `frontend/src/stores/fabrications.ts`
**Step 1: Créer `stores/achats.ts`**
```typescript
// frontend/src/stores/achats.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { achatsApi, type AchatIntrant } from '@/api/achats'
export const useAchatsStore = defineStore('achats', () => {
const achats = ref<AchatIntrant[]>([])
const loading = ref(false)
async function fetchAll(params?: { categorie?: string }) {
loading.value = true
try { achats.value = await achatsApi.list(params) }
finally { loading.value = false }
}
async function create(a: Partial<AchatIntrant>) {
const created = await achatsApi.create(a)
achats.value.unshift(created)
return created
}
async function remove(id: number) {
await achatsApi.delete(id)
achats.value = achats.value.filter(a => a.id !== id)
}
return { achats, loading, fetchAll, create, remove }
})
```
**Step 2: Créer `stores/fabrications.ts`**
```typescript
// frontend/src/stores/fabrications.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fabricationsApi, type Fabrication } from '@/api/fabrications'
export const useFabricationsStore = defineStore('fabrications', () => {
const fabrications = ref<Fabrication[]>([])
const loading = ref(false)
async function fetchAll(params?: { type?: string; statut?: string }) {
loading.value = true
try { fabrications.value = await fabricationsApi.list(params) }
finally { loading.value = false }
}
async function create(f: Partial<Fabrication>) {
const created = await fabricationsApi.create(f)
fabrications.value.unshift(created)
return created
}
async function updateStatut(id: number, statut: string) {
const updated = await fabricationsApi.updateStatut(id, statut)
const idx = fabrications.value.findIndex(f => f.id === id)
if (idx !== -1) fabrications.value[idx] = updated
return updated
}
async function remove(id: number) {
await fabricationsApi.delete(id)
fabrications.value = fabrications.value.filter(f => f.id !== id)
}
return { fabrications, loading, fetchAll, create, updateStatut, remove }
})
```
**Step 3: Commit**
```bash
git add frontend/src/stores/achats.ts frontend/src/stores/fabrications.ts
git commit -m "feat(intrants): Pinia stores for achats + fabrications"
```
---
### Task 8 : Route + Navigation
**Files:**
- Modify: `frontend/src/router/index.ts`
- Modify: `frontend/src/App.vue`
- Modify: `frontend/src/components/AppDrawer.vue`
**Step 1: Ajouter la route dans `router/index.ts`**
Après `{ path: '/outils', ... }` :
```typescript
{ path: '/intrants', component: () => import('@/views/IntratsView.vue') },
```
**Step 2: Ajouter dans `App.vue` (liste `links`)**
Après `{ to: '/outils', label: 'Outils', icon: '🔧' }` :
```typescript
{ to: '/intrants', label: 'Intrants', icon: '🧪' },
```
**Step 3: Ajouter dans `AppDrawer.vue` (liste `links`)**
Après `{ to: '/outils', label: 'Outils' }` :
```typescript
{ to: '/intrants', label: '🧪 Intrants' },
```
**Step 4: Commit**
```bash
git add frontend/src/router/index.ts frontend/src/App.vue frontend/src/components/AppDrawer.vue
git commit -m "feat(intrants): add /intrants route + sidebar nav"
```
---
### Task 9 : IntratsView.vue — structure + onglet Achats
**Files:**
- Create: `frontend/src/views/IntratsView.vue`
**Step 1: Créer la vue complète**
```vue
<!-- frontend/src/views/IntratsView.vue -->
<template>
<div class="p-4 max-w-[1800px] mx-auto space-y-6">
<!-- En-tête -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h1 class="text-3xl font-bold text-yellow tracking-tight">🧪 Intrants</h1>
<!-- Onglets -->
<div class="flex items-center gap-2 bg-bg-hard rounded-xl p-1 border border-bg-soft">
<button v-for="tab in tabs" :key="tab.key"
@click="activeTab = tab.key"
:class="['px-4 py-2 rounded-lg text-sm font-bold transition-all',
activeTab === tab.key ? 'bg-yellow text-bg' : 'text-text-muted hover:text-text']">
{{ tab.label }}
</button>
</div>
<button @click="openCreateForm"
class="btn-primary !bg-yellow !text-bg flex items-center gap-2 rounded-lg py-2 px-4 shadow-lg hover:scale-105 transition-all font-bold">
<span class="text-lg">+</span> {{ activeTab === 'achats' ? 'Ajouter un achat' : 'Nouvelle fabrication' }}
</button>
</div>
<!-- ====== ONGLET ACHATS ====== -->
<div v-if="activeTab === 'achats'">
<!-- Filtres -->
<div class="flex flex-wrap gap-2 mb-4">
<button v-for="cat in categoriesAchat" :key="cat.val"
@click="filterCat = cat.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
filterCat === cat.val ? 'bg-yellow text-bg border-yellow' : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ cat.label }}
</button>
</div>
<!-- Grille achats -->
<div v-if="achatsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="i in 6" :key="i" class="card-jardin h-32 animate-pulse opacity-20"></div>
</div>
<div v-else-if="filteredAchats.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="a in filteredAchats" :key="a.id"
class="card-jardin !p-0 overflow-hidden flex flex-col hover:border-yellow/40 transition-all border-l-[6px] cursor-pointer"
:style="{ borderLeftColor: catAchatColor(a.categorie) }"
@click="openDetailAchat(a)">
<div class="p-4 flex-1">
<div class="flex items-start justify-between gap-2 mb-2">
<div>
<span :class="['text-[8px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded', catAchatTextClass(a.categorie)]">
{{ a.categorie }}
</span>
<h3 class="text-text font-bold text-lg mt-1 leading-tight">{{ a.nom }}</h3>
<p v-if="a.marque" class="text-text-muted text-xs">{{ a.marque }}</p>
</div>
<span v-if="a.prix" class="text-yellow font-black text-lg shrink-0">{{ a.prix.toFixed(2) }}</span>
</div>
<div class="flex flex-wrap gap-2 mt-3">
<span v-if="a.boutique_nom" class="text-[10px] bg-bg/40 px-2 py-0.5 rounded border border-bg-soft text-text-muted">
🛒 {{ a.boutique_nom }}
</span>
<span v-if="a.poids" class="text-[10px] bg-bg/40 px-2 py-0.5 rounded border border-bg-soft text-text-muted">
{{ a.poids }}
</span>
<span v-if="a.dluo" :class="['text-[10px] px-2 py-0.5 rounded border',
isDluoExpired(a.dluo) ? 'bg-red/10 border-red/40 text-red' : 'bg-bg/40 border-bg-soft text-text-muted']">
📅 DLUO: {{ a.dluo }}{{ isDluoExpired(a.dluo) ? ' ⚠️' : '' }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-16 text-text-muted/40 italic">
Aucun achat enregistré. Commencez par ajouter un terreau, engrais ou traitement.
</div>
</div>
<!-- ====== ONGLET FABRICATIONS ====== -->
<div v-if="activeTab === 'fabrications'">
<!-- Filtres -->
<div class="flex flex-wrap gap-2 mb-4">
<button v-for="t in typesFabrication" :key="t.val"
@click="filterType = t.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
filterType === t.val ? 'bg-yellow text-bg border-yellow' : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ t.label }}
</button>
<div class="w-px bg-bg-soft mx-1"></div>
<button v-for="s in statutsFabrication" :key="s.val"
@click="filterStatut = s.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
filterStatut === s.val ? `${s.bgClass} text-bg border-transparent` : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ s.label }}
</button>
</div>
<!-- Grille fabrications -->
<div v-if="fabricationsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
</div>
<div v-else-if="filteredFabricatons.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="f in filteredFabricatons" :key="f.id"
class="card-jardin !p-0 overflow-hidden flex flex-col hover:border-yellow/40 transition-all border-l-[6px] cursor-pointer"
:style="{ borderLeftColor: statutColor(f.statut || 'en_cours') }"
@click="openDetailFabrication(f)">
<div class="p-4 flex-1">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex-1 min-w-0">
<span :class="['text-[8px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded', typeFabTextClass(f.type)]">
{{ f.type }}
</span>
<h3 class="text-text font-bold text-lg mt-1 leading-tight">{{ f.nom }}</h3>
</div>
<span :class="['text-[9px] font-black px-2 py-0.5 rounded-full border shrink-0', statutBadgeClass(f.statut || 'en_cours')]">
{{ statutLabel(f.statut || 'en_cours') }}
</span>
</div>
<!-- Ingrédients résumé -->
<p v-if="f.ingredients?.length" class="text-text-muted text-[10px] mb-2 truncate">
🌿 {{ f.ingredients.map(i => i.nom).join(', ') }}
</p>
<div class="flex flex-wrap gap-2">
<span v-if="f.date_fin_prevue" class="text-[10px] bg-bg/40 px-2 py-0.5 rounded border border-bg-soft text-text-muted">
📅 Prêt le {{ f.date_fin_prevue }}
</span>
<span v-if="f.quantite_produite" class="text-[10px] bg-bg/40 px-2 py-0.5 rounded border border-bg-soft text-text-muted">
{{ f.quantite_produite }}
</span>
</div>
</div>
<!-- Boutons rapides statut -->
<div v-if="f.statut === 'en_cours'" class="flex border-t border-bg-soft">
<button @click.stop="quickStatut(f, 'pret')"
class="flex-1 py-2 text-[10px] font-black text-green hover:bg-green/10 transition-colors">
Prêt
</button>
<div class="w-px bg-bg-soft"></div>
<button @click.stop="quickStatut(f, 'echec')"
class="flex-1 py-2 text-[10px] font-black text-red hover:bg-red/10 transition-colors">
Échec
</button>
</div>
</div>
</div>
<div v-else class="text-center py-16 text-text-muted/40 italic">
Aucune fabrication. Commencez par créer un compost ou une décoction.
</div>
</div>
<!-- ====== POPUP DÉTAIL ACHAT ====== -->
<div v-if="detailAchat" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="detailAchat = null">
<div class="bg-bg-hard rounded-3xl w-full max-w-lg border border-bg-soft shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="p-5 border-b border-bg-soft flex justify-between items-start" :style="{ borderLeft: `8px solid ${catAchatColor(detailAchat.categorie)}` }">
<div>
<span :class="['text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded bg-bg/50', catAchatTextClass(detailAchat.categorie)]">
{{ detailAchat.categorie }}
</span>
<h2 class="text-text font-black text-2xl mt-1">{{ detailAchat.nom }}</h2>
<p v-if="detailAchat.marque" class="text-text-muted text-sm">{{ detailAchat.marque }}</p>
</div>
<button @click="detailAchat = null" class="text-text-muted hover:text-red text-2xl"></button>
</div>
<div class="p-5 overflow-y-auto space-y-4">
<div class="grid grid-cols-2 gap-3">
<div v-if="detailAchat.prix" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Prix</span>
<span class="text-yellow font-black text-lg">{{ detailAchat.prix.toFixed(2) }} </span>
</div>
<div v-if="detailAchat.poids" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Poids / Qté</span>
<span class="text-text font-bold">{{ detailAchat.poids }}</span>
</div>
<div v-if="detailAchat.boutique_nom" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Enseigne</span>
<span class="text-text font-bold">{{ detailAchat.boutique_nom }}</span>
</div>
<div v-if="detailAchat.date_achat" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Date d'achat</span>
<span class="text-text text-sm">{{ detailAchat.date_achat }}</span>
</div>
<div v-if="detailAchat.dluo" class="bg-bg/30 p-3 rounded-xl border border-bg-soft col-span-2">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">DLUO</span>
<span :class="['font-bold', isDluoExpired(detailAchat.dluo) ? 'text-red' : 'text-green']">
{{ detailAchat.dluo }}{{ isDluoExpired(detailAchat.dluo) ? ' Dépassée' : ' Valide' }}
</span>
</div>
</div>
<div v-if="detailAchat.boutique_url">
<a :href="detailAchat.boutique_url" target="_blank" rel="noopener"
class="text-blue text-sm hover:underline">🔗 Voir le produit en ligne</a>
</div>
<div v-if="detailAchat.notes" class="bg-bg/40 p-4 rounded-2xl border-l-4 border-yellow/30 text-text/90 text-sm italic whitespace-pre-line">
{{ detailAchat.notes }}
</div>
</div>
<div class="p-4 border-t border-bg-soft flex gap-3">
<button @click="startEditAchat(detailAchat)" class="btn-primary !bg-yellow !text-bg flex-1 py-2 font-black uppercase text-xs">Modifier</button>
<button @click="deleteAchat(detailAchat.id!)" class="btn-outline !border-red/20 !text-red hover:bg-red/10 px-4 py-2 font-black uppercase text-xs">Supprimer</button>
</div>
</div>
</div>
<!-- ====== POPUP DÉTAIL FABRICATION ====== -->
<div v-if="detailFabrication" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="detailFabrication = null">
<div class="bg-bg-hard rounded-3xl w-full max-w-lg border border-bg-soft shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="p-5 border-b border-bg-soft flex justify-between items-start" :style="{ borderLeft: `8px solid ${statutColor(detailFabrication.statut || 'en_cours')}` }">
<div>
<div class="flex items-center gap-2 mb-1">
<span :class="['text-[9px] font-black uppercase px-1.5 py-0.5 rounded', typeFabTextClass(detailFabrication.type)]">
{{ detailFabrication.type }}
</span>
<span :class="['text-[9px] font-black px-2 py-0.5 rounded-full border', statutBadgeClass(detailFabrication.statut || 'en_cours')]">
{{ statutLabel(detailFabrication.statut || 'en_cours') }}
</span>
</div>
<h2 class="text-text font-black text-2xl">{{ detailFabrication.nom }}</h2>
</div>
<button @click="detailFabrication = null" class="text-text-muted hover:text-red text-2xl">✕</button>
</div>
<div class="p-5 overflow-y-auto space-y-4">
<!-- Dates -->
<div class="grid grid-cols-2 gap-3">
<div v-if="detailFabrication.date_debut" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Début</span>
<span class="text-text text-sm">{{ detailFabrication.date_debut }}</span>
</div>
<div v-if="detailFabrication.date_fin_prevue" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Prêt le</span>
<span class="text-text text-sm">{{ detailFabrication.date_fin_prevue }}</span>
</div>
<div v-if="detailFabrication.quantite_produite" class="bg-bg/30 p-3 rounded-xl border border-bg-soft col-span-2">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Quantité produite</span>
<span class="text-text font-bold">{{ detailFabrication.quantite_produite }}</span>
</div>
</div>
<!-- Ingrédients -->
<div v-if="detailFabrication.ingredients?.length" class="space-y-2">
<h3 class="text-[9px] font-black text-text-muted uppercase tracking-widest">🌿 Ingrédients</h3>
<div class="space-y-1">
<div v-for="(ing, i) in detailFabrication.ingredients" :key="i"
class="flex justify-between items-center bg-bg/30 px-3 py-2 rounded-lg border border-bg-soft">
<span class="text-text text-sm">{{ ing.nom }}</span>
<span class="text-yellow font-bold text-sm">{{ ing.quantite }}</span>
</div>
</div>
</div>
<!-- Notes -->
<div v-if="detailFabrication.notes" class="bg-bg/40 p-4 rounded-2xl border-l-4 border-yellow/30 text-text/90 text-sm italic whitespace-pre-line">
{{ detailFabrication.notes }}
</div>
<!-- Changement de statut -->
<div class="space-y-2">
<h3 class="text-[9px] font-black text-text-muted uppercase tracking-widest">Changer le statut</h3>
<div class="flex gap-2 flex-wrap">
<button v-for="s in statutsFabrication.slice(1)" :key="s.val"
@click="changeStatut(detailFabrication, s.val)"
:disabled="detailFabrication.statut === s.val"
:class="['px-3 py-1 rounded-lg text-[10px] font-black uppercase border transition-all',
detailFabrication.statut === s.val
? `${s.bgClass} text-bg border-transparent cursor-default`
: 'border-bg-soft text-text-muted hover:text-text']">
{{ s.label }}
</button>
</div>
</div>
</div>
<div class="p-4 border-t border-bg-soft flex gap-3">
<button @click="startEditFabrication(detailFabrication)" class="btn-primary !bg-yellow !text-bg flex-1 py-2 font-black uppercase text-xs">Modifier</button>
<button @click="deleteFabrication(detailFabrication.id!)" class="btn-outline !border-red/20 !text-red hover:bg-red/10 px-4 py-2 font-black uppercase text-xs">Supprimer</button>
</div>
</div>
</div>
<!-- ====== FORMULAIRE ACHAT ====== -->
<div v-if="showFormAchat" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center p-4" @click.self="closeFormAchat">
<div class="bg-bg-hard rounded-3xl p-6 w-full max-w-2xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-xl uppercase">{{ editAchat ? 'Modifier l\'achat' : 'Nouvel achat' }}</h2>
<button @click="closeFormAchat" class="text-text-muted hover:text-red text-2xl"></button>
</div>
<form @submit.prevent="submitAchat" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Catégorie *</label>
<div class="flex gap-2 flex-wrap">
<button v-for="cat in categoriesAchat.slice(1)" :key="cat.val" type="button"
@click="formAchat.categorie = cat.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase border transition-all',
formAchat.categorie === cat.val ? 'border-transparent text-bg' : 'border-bg-soft text-text-muted hover:text-text']"
:style="formAchat.categorie === cat.val ? { background: catAchatColor(cat.val) } : {}">
{{ cat.label }}
</button>
</div>
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom *</label>
<input v-model="formAchat.nom" required class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" placeholder="Ex: Terreau universel Floragard" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Marque</label>
<input v-model="formAchat.marque" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Enseigne</label>
<select v-model="formAchat.boutique_nom" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-yellow appearance-none">
<option value=""> Non renseigné </option>
<option v-for="b in BOUTIQUES" :key="b" :value="b">{{ b }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Prix ()</label>
<input v-model.number="formAchat.prix" type="number" step="0.01" min="0" placeholder="0.00" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Poids / Quantité</label>
<input v-model="formAchat.poids" placeholder="ex: 20L, 1kg, 500ml" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Date d'achat</label>
<input v-model="formAchat.date_achat" type="date" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">DLUO</label>
<input v-model="formAchat.dluo" type="date" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">URL produit</label>
<input v-model="formAchat.boutique_url" type="url" placeholder="https://..." class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Notes</label>
<textarea v-model="formAchat.notes" rows="2" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none resize-none" />
</div>
<div class="md:col-span-2 flex justify-between pt-4 border-t border-bg-soft">
<button type="button" @click="closeFormAchat" class="text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
<button type="submit" :disabled="submitting" class="btn-primary px-8 py-3 !bg-yellow !text-bg font-black">
{{ editAchat ? 'Sauvegarder' : 'Enregistrer' }}
</button>
</div>
</form>
</div>
</div>
<!-- ====== FORMULAIRE FABRICATION ====== -->
<div v-if="showFormFab" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center p-4" @click.self="closeFormFab">
<div class="bg-bg-hard rounded-3xl p-6 w-full max-w-2xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-xl uppercase">{{ editFab ? 'Modifier' : 'Nouvelle fabrication' }}</h2>
<button @click="closeFormFab" class="text-text-muted hover:text-red text-2xl">✕</button>
</div>
<form @submit.prevent="submitFab" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Type *</label>
<div class="flex gap-2 flex-wrap">
<button v-for="t in typesFabrication.slice(1)" :key="t.val" type="button"
@click="formFab.type = t.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase border transition-all',
formFab.type === t.val ? `${t.bgClass} text-bg border-transparent` : 'border-bg-soft text-text-muted hover:text-text']">
{{ t.label }}
</button>
</div>
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom *</label>
<input v-model="formFab.nom" required class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" placeholder="Ex: Purin d'ortie mai 2026" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Date de début</label>
<input v-model="formFab.date_debut" type="date" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Date prévue prête</label>
<input v-model="formFab.date_fin_prevue" type="date" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Statut</label>
<select v-model="formFab.statut" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-yellow appearance-none">
<option v-for="s in statutsFabrication.slice(1)" :key="s.val" :value="s.val">{{ s.label }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Quantité produite</label>
<input v-model="formFab.quantite_produite" placeholder="ex: 8L, 50kg" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<!-- Ingrédients -->
<div class="md:col-span-2 bg-bg/40 border border-bg-soft rounded-2xl p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-[10px] font-black text-text-muted uppercase tracking-widest">🌿 Ingrédients</span>
<button type="button" @click="addIngredient"
class="px-2 py-0.5 rounded-full text-[10px] font-bold border border-green/40 text-green hover:bg-green/10 transition-all">
+ Ajouter
</button>
</div>
<div class="space-y-2">
<div v-for="(ing, i) in formFab.ingredients" :key="i" class="flex gap-2 items-center">
<input v-model="ing.nom" placeholder="Ingrédient" class="flex-1 bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm focus:border-yellow outline-none" />
<input v-model="ing.quantite" placeholder="Qté" class="w-24 bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm focus:border-yellow outline-none" />
<button type="button" @click="removeIngredient(i)" class="text-red/60 hover:text-red text-lg leading-none">✕</button>
</div>
<p v-if="!formFab.ingredients.length" class="text-text-muted/40 text-xs italic">Aucun ingrédient ajouté</p>
</div>
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Notes / Recette</label>
<textarea v-model="formFab.notes" rows="3" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none resize-none" placeholder="Instructions, observations, recette..." />
</div>
<div class="md:col-span-2 flex justify-between pt-4 border-t border-bg-soft">
<button type="button" @click="closeFormFab" class="text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
<button type="submit" :disabled="submitting" class="btn-primary px-8 py-3 !bg-yellow !text-bg font-black">
{{ editFab ? 'Sauvegarder' : 'Créer' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import { useAchatsStore } from '@/stores/achats'
import { useFabricationsStore } from '@/stores/fabrications'
import type { AchatIntrant } from '@/api/achats'
import type { Fabrication } from '@/api/fabrications'
import { useToast } from '@/composables/useToast'
const achatsStore = useAchatsStore()
const fabricationsStore = useFabricationsStore()
const toast = useToast()
const activeTab = ref<'achats' | 'fabrications'>('achats')
const filterCat = ref('')
const filterType = ref('')
const filterStatut = ref('')
const submitting = ref(false)
// Détails
const detailAchat = ref<AchatIntrant | null>(null)
const detailFabrication = ref<Fabrication | null>(null)
// Formulaire achat
const showFormAchat = ref(false)
const editAchat = ref<AchatIntrant | null>(null)
const formAchat = reactive<Partial<AchatIntrant>>({
categorie: 'terreau', nom: '', marque: '', boutique_nom: '',
boutique_url: '', prix: undefined, poids: '', date_achat: '', dluo: '', notes: '',
})
// Formulaire fabrication
const showFormFab = ref(false)
const editFab = ref<Fabrication | null>(null)
const formFab = reactive({
type: 'purin', nom: '', date_debut: '', date_fin_prevue: '',
statut: 'en_cours', quantite_produite: '', notes: '',
ingredients: [] as { nom: string; quantite: string }[],
})
const tabs = [
{ key: 'achats', label: '🛒 Achats' },
{ key: 'fabrications', label: '🌿 Fabrications' },
]
const BOUTIQUES = [
'Gamm Vert', 'Lidl', 'Super U', 'Intermarché', 'Truffaut', 'Botanic',
'Amazon', 'Graines Baumaux', 'Vilmorin', 'Germinance', 'Direct producteur',
'Marché local', 'Autre',
]
const categoriesAchat = [
{ val: '', label: 'Tous' },
{ val: 'terreau', label: '🪨 Terreau' },
{ val: 'engrais', label: '🌿 Engrais' },
{ val: 'traitement', label: '💊 Traitement' },
{ val: 'autre', label: 'Autre' },
]
const typesFabrication = [
{ val: '', label: 'Tous', bgClass: 'bg-yellow' },
{ val: 'compost', label: '♻️ Compost', bgClass: 'bg-orange' },
{ val: 'decoction', label: '🫖 Décoction', bgClass: 'bg-blue' },
{ val: 'purin', label: '🌱 Purin', bgClass: 'bg-green' },
{ val: 'autre', label: 'Autre', bgClass: 'bg-text-muted' },
]
const statutsFabrication = [
{ val: '', label: 'Tous', bgClass: 'bg-yellow' },
{ val: 'en_cours', label: '⏳ En cours', bgClass: 'bg-orange' },
{ val: 'pret', label: '✓ Prêt', bgClass: 'bg-green' },
{ val: 'utilise', label: '✓ Utilisé', bgClass: 'bg-bg-soft' },
{ val: 'echec', label: '✗ Échec', bgClass: 'bg-red' },
]
const filteredAchats = computed(() => {
let list = achatsStore.achats
if (filterCat.value) list = list.filter(a => a.categorie === filterCat.value)
return list
})
const filteredFabricatons = computed(() => {
let list = fabricationsStore.fabrications
if (filterType.value) list = list.filter(f => f.type === filterType.value)
if (filterStatut.value) list = list.filter(f => f.statut === filterStatut.value)
return list
})
function isDluoExpired(dluo: string) {
return !!dluo && new Date(dluo) < new Date()
}
function catAchatColor(cat: string) {
return ({ terreau: '#fe8019', engrais: '#b8bb26', traitement: '#83a598', autre: '#928374' } as any)[cat] || '#928374'
}
function catAchatTextClass(cat: string) {
return ({ terreau: 'text-orange', engrais: 'text-green', traitement: 'text-blue', autre: 'text-text-muted' } as any)[cat] || 'text-text-muted'
}
function statutColor(statut: string) {
return ({ en_cours: '#fe8019', pret: '#b8bb26', utilise: '#928374', echec: '#fb4934' } as any)[statut] || '#928374'
}
function statutBadgeClass(statut: string) {
return ({
en_cours: 'bg-orange/10 border-orange/40 text-orange',
pret: 'bg-green/10 border-green/40 text-green',
utilise: 'bg-bg-soft border-bg-soft text-text-muted',
echec: 'bg-red/10 border-red/40 text-red',
} as any)[statut] || ''
}
function statutLabel(statut: string) {
return ({ en_cours: '⏳ En cours', pret: '✓ Prêt', utilise: '✓ Utilisé', echec: '✗ Échec' } as any)[statut] || statut
}
function typeFabTextClass(type: string) {
return ({ compost: 'text-orange', decoction: 'text-blue', purin: 'text-green', autre: 'text-text-muted' } as any)[type] || 'text-text-muted'
}
// ---- Achats ----
function openCreateForm() {
if (activeTab.value === 'achats') {
editAchat.value = null
Object.assign(formAchat, { categorie: 'terreau', nom: '', marque: '', boutique_nom: '', boutique_url: '', prix: undefined, poids: '', date_achat: '', dluo: '', notes: '' })
showFormAchat.value = true
} else {
editFab.value = null
Object.assign(formFab, { type: 'purin', nom: '', date_debut: '', date_fin_prevue: '', statut: 'en_cours', quantite_produite: '', notes: '', ingredients: [] })
showFormFab.value = true
}
}
function openDetailAchat(a: AchatIntrant) { detailAchat.value = a }
function startEditAchat(a: AchatIntrant) {
detailAchat.value = null
editAchat.value = a
Object.assign(formAchat, { ...a })
showFormAchat.value = true
}
function closeFormAchat() { showFormAchat.value = false; editAchat.value = null }
async function submitAchat() {
if (submitting.value) return
submitting.value = true
try {
const payload = { ...formAchat, prix: formAchat.prix ?? undefined }
if (editAchat.value) {
await axios.put(`/api/achats/${editAchat.value.id}`, payload)
await achatsStore.fetchAll()
toast.success('Achat modifié')
} else {
await achatsStore.create(payload)
toast.success('Achat enregistré')
}
closeFormAchat()
} catch { /* intercepteur */ } finally { submitting.value = false }
}
async function deleteAchat(id: number) {
if (!confirm('Supprimer cet achat ?')) return
await achatsStore.remove(id)
detailAchat.value = null
toast.success('Achat supprimé')
}
// ---- Fabrications ----
function openDetailFabrication(f: Fabrication) { detailFabrication.value = f }
function startEditFabrication(f: Fabrication) {
detailFabrication.value = null
editFab.value = f
Object.assign(formFab, {
...f,
ingredients: f.ingredients ? [...f.ingredients.map(i => ({ ...i }))] : [],
})
showFormFab.value = true
}
function closeFormFab() { showFormFab.value = false; editFab.value = null }
function addIngredient() { formFab.ingredients.push({ nom: '', quantite: '' }) }
function removeIngredient(i: number) { formFab.ingredients.splice(i, 1) }
async function submitFab() {
if (submitting.value) return
submitting.value = true
try {
const payload = { ...formFab, ingredients: formFab.ingredients.filter(i => i.nom) }
if (editFab.value) {
await axios.put(`/api/fabrications/${editFab.value.id}`, payload)
await fabricationsStore.fetchAll()
toast.success('Fabrication modifiée')
} else {
await fabricationsStore.create(payload)
toast.success('Fabrication créée')
}
closeFormFab()
} catch { /* intercepteur */ } finally { submitting.value = false }
}
async function quickStatut(f: Fabrication, statut: string) {
await fabricationsStore.updateStatut(f.id!, statut)
toast.success(`Statut → ${statutLabel(statut)}`)
}
async function changeStatut(f: Fabrication, statut: string) {
await fabricationsStore.updateStatut(f.id!, statut)
detailFabrication.value = fabricationsStore.fabrications.find(x => x.id === f.id) ?? detailFabrication.value
toast.success(`Statut → ${statutLabel(statut)}`)
}
async function deleteFabrication(id: number) {
if (!confirm('Supprimer cette fabrication ?')) return
await fabricationsStore.remove(id)
detailFabrication.value = null
toast.success('Fabrication supprimée')
}
onMounted(async () => {
await Promise.all([achatsStore.fetchAll(), fabricationsStore.fetchAll()])
})
</script>
```
**Step 2: Vérifier la compilation**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: `✓ built in X.XXs`
**Step 3: Commit final**
```bash
git add frontend/src/views/IntratsView.vue
git commit -m "feat(intrants): IntratsView with Achats + Fabrications tabs"
```
---
## Récapitulatif des commits
```
feat(intrants): add AchatIntrant + Fabrication SQLModel
feat(intrants): add migration for achat_intrant + fabrication tables
feat(intrants): CRUD router for achats
feat(intrants): CRUD + statut router for fabrications
feat(intrants): register achats + fabrications routers
feat(intrants): frontend API clients for achats + fabrications
feat(intrants): Pinia stores for achats + fabrications
feat(intrants): add /intrants route + sidebar nav
feat(intrants): IntratsView with Achats + Fabrications tabs
```
---
## Test manuel final
1. Démarrer l'app : `docker compose up --build`
2. Aller sur `http://localhost/intrants`
3. Onglet **Achats** : créer un terreau "Floragard 40L", Gamm Vert, 8.99€, DLUO dans le passé → badge rouge ⚠️
4. Onglet **Fabrications** : créer un purin d'ortie avec 3 ingrédients → bouton "✓ Prêt" → statut change en vert