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

54 KiB
Raw Blame History

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

# 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 :

from app.models.intrant import AchatIntrant, Fabrication  # noqa

Step 3: Vérifier que SQLModel crée bien les tables

cd backend
python3 -c "from app.models.intrant import AchatIntrant, Fabrication; print('OK')"

Expected: OK

Step 4: Commit

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 :

    "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

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

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

# 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

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

# 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

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, :

    achats,
    fabrications,

Step 2: Ajouter les includes

Après app.include_router(identify.router, prefix="/api"), ajouter :

app.include_router(achats.router, prefix="/api")
app.include_router(fabrications.router, prefix="/api")

Step 3: Tester l'API

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

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

// 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

// 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

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

// 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

// 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

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', ... } :

{ path: '/intrants', component: () => import('@/views/IntratsView.vue') },

Step 2: Ajouter dans App.vue (liste links)

Après { to: '/outils', label: 'Outils', icon: '🔧' } :

{ to: '/intrants', label: 'Intrants', icon: '🧪' },

Step 3: Ajouter dans AppDrawer.vue (liste links)

Après { to: '/outils', label: 'Outils' } :

{ to: '/intrants', label: '🧪 Intrants' },

Step 4: Commit

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

<!-- 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

cd frontend && npm run build 2>&1 | tail -5

Expected: ✓ built in X.XXs

Step 3: Commit final

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