1327 lines
54 KiB
Markdown
1327 lines
54 KiB
Markdown
# 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
|