Plan complet : Docker, FastAPI + SQLModel + tests TDD, Vue 3 + Tailwind Gruvbox. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2385 lines
72 KiB
Markdown
2385 lines
72 KiB
Markdown
# Webapp Jardin — Plan d'implémentation
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Scaffold complet (Docker + FastAPI backend + Vue 3 frontend) avec CRUD fonctionnel pour jardins, variétés, plantations et tâches, données de démo incluses.
|
||
|
||
**Architecture:** Monorepo `backend/` (FastAPI + SQLModel + SQLite) + `frontend/` (Vue 3 + Vite + TypeScript + Tailwind + Pinia). Docker Compose orchestre les deux services + volume persistant `data/`.
|
||
|
||
**Tech Stack:** Python 3.12, FastAPI 0.115, SQLModel, SQLite, Vue 3, Vite, TypeScript, Tailwind CSS 3, Pinia, Vue Router 4, Axios, Docker Compose, Nginx
|
||
|
||
---
|
||
|
||
### Task 1: Structure du projet + Docker Compose
|
||
|
||
**Files:**
|
||
- Create: `docker-compose.yml`
|
||
- Create: `backend/Dockerfile`
|
||
- Create: `frontend/Dockerfile`
|
||
- Create: `frontend/nginx.conf`
|
||
- Create: `.env.example`
|
||
- Create: `data/.gitkeep`
|
||
- Create: `.gitignore`
|
||
|
||
**Step 1: Créer les répertoires**
|
||
|
||
```bash
|
||
mkdir -p backend/app/{models,routers} backend/tests data \
|
||
frontend/src/{api,components,router,stores,views}
|
||
touch data/.gitkeep backend/app/__init__.py backend/app/models/__init__.py backend/app/routers/__init__.py backend/tests/__init__.py
|
||
```
|
||
|
||
**Step 2: Créer `.env.example`**
|
||
|
||
```env
|
||
BACKEND_PORT=8000
|
||
CORS_ORIGINS=http://localhost:5173,http://localhost
|
||
DATABASE_URL=sqlite:////data/jardin.db
|
||
UPLOAD_DIR=/data/uploads
|
||
VITE_API_URL=http://localhost:8000
|
||
```
|
||
|
||
**Step 3: Créer `.gitignore`**
|
||
|
||
```
|
||
data/*.db
|
||
data/uploads/
|
||
backend/__pycache__/
|
||
backend/.venv/
|
||
backend/*.pyc
|
||
frontend/node_modules/
|
||
frontend/dist/
|
||
.env
|
||
*.egg-info/
|
||
.pytest_cache/
|
||
```
|
||
|
||
**Step 4: Créer `docker-compose.yml`**
|
||
|
||
```yaml
|
||
services:
|
||
backend:
|
||
build: ./backend
|
||
volumes:
|
||
- ./data:/data
|
||
ports:
|
||
- "8000:8000"
|
||
environment:
|
||
- DATABASE_URL=sqlite:////data/jardin.db
|
||
- UPLOAD_DIR=/data/uploads
|
||
- CORS_ORIGINS=http://localhost
|
||
restart: unless-stopped
|
||
|
||
frontend:
|
||
build: ./frontend
|
||
ports:
|
||
- "80:80"
|
||
depends_on:
|
||
- backend
|
||
restart: unless-stopped
|
||
```
|
||
|
||
**Step 5: Créer `backend/Dockerfile`**
|
||
|
||
```dockerfile
|
||
FROM python:3.12-slim
|
||
WORKDIR /app
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt
|
||
COPY . .
|
||
RUN mkdir -p /data/uploads
|
||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||
```
|
||
|
||
**Step 6: Créer `frontend/Dockerfile`**
|
||
|
||
```dockerfile
|
||
FROM node:22-alpine AS build
|
||
WORKDIR /app
|
||
COPY package*.json ./
|
||
RUN npm ci
|
||
COPY . .
|
||
RUN npm run build
|
||
|
||
FROM nginx:alpine
|
||
COPY --from=build /app/dist /usr/share/nginx/html
|
||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||
EXPOSE 80
|
||
```
|
||
|
||
**Step 7: Créer `frontend/nginx.conf`**
|
||
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
location /api/ {
|
||
proxy_pass http://backend:8000;
|
||
proxy_set_header Host $host;
|
||
}
|
||
|
||
location /uploads/ {
|
||
proxy_pass http://backend:8000;
|
||
}
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 8: Commit**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "chore: scaffold projet + docker-compose"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Backend — Setup FastAPI + DB
|
||
|
||
**Files:**
|
||
- Create: `backend/requirements.txt`
|
||
- Create: `backend/app/config.py`
|
||
- Create: `backend/app/database.py`
|
||
- Create: `backend/app/seed.py`
|
||
- Create: `backend/app/main.py`
|
||
|
||
**Step 1: Créer `backend/requirements.txt`**
|
||
|
||
```
|
||
fastapi==0.115.5
|
||
uvicorn[standard]==0.32.1
|
||
sqlmodel==0.0.22
|
||
python-multipart==0.0.12
|
||
aiofiles==24.1.0
|
||
pytest==8.3.3
|
||
httpx==0.28.0
|
||
```
|
||
|
||
**Step 2: Créer `backend/app/config.py`**
|
||
|
||
```python
|
||
import os
|
||
|
||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./jardin.db")
|
||
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./data/uploads")
|
||
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||
```
|
||
|
||
**Step 3: Créer `backend/app/database.py`**
|
||
|
||
```python
|
||
from sqlmodel import SQLModel, create_engine, Session
|
||
from app.config import DATABASE_URL
|
||
|
||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||
|
||
|
||
def get_session():
|
||
with Session(engine) as session:
|
||
yield session
|
||
|
||
|
||
def create_db_and_tables():
|
||
SQLModel.metadata.create_all(engine)
|
||
```
|
||
|
||
**Step 4: Créer `backend/app/seed.py`** (stub, rempli en Task 6)
|
||
|
||
```python
|
||
def run_seed():
|
||
pass
|
||
```
|
||
|
||
**Step 5: Créer `backend/app/main.py`**
|
||
|
||
```python
|
||
import os
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from app.config import CORS_ORIGINS, UPLOAD_DIR
|
||
from app.database import create_db_and_tables
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||
import app.models # noqa — enregistre tous les modèles avant create_all
|
||
create_db_and_tables()
|
||
from app.seed import run_seed
|
||
run_seed()
|
||
yield
|
||
|
||
|
||
app = FastAPI(title="Jardin API", lifespan=lifespan)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=CORS_ORIGINS,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.get("/api/health")
|
||
def health():
|
||
return {"status": "ok"}
|
||
|
||
|
||
# Routers enregistrés après leur création (Tasks 4-6)
|
||
```
|
||
|
||
**Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/
|
||
git commit -m "feat(backend): setup FastAPI + SQLite + config"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Backend — Modèles SQLModel
|
||
|
||
**Files:**
|
||
- Create: `backend/app/models/garden.py`
|
||
- Create: `backend/app/models/plant.py`
|
||
- Create: `backend/app/models/planting.py`
|
||
- Create: `backend/app/models/task.py`
|
||
- Create: `backend/app/models/settings.py`
|
||
- Modify: `backend/app/models/__init__.py`
|
||
|
||
**Step 1: Créer `backend/app/models/garden.py`**
|
||
|
||
```python
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
from sqlmodel import Field, SQLModel
|
||
|
||
|
||
class Garden(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
nom: str
|
||
description: Optional[str] = None
|
||
type: str = "plein_air" # plein_air | serre | tunnel
|
||
latitude: Optional[float] = None
|
||
longitude: Optional[float] = None
|
||
altitude: Optional[float] = None
|
||
adresse: Optional[str] = None
|
||
exposition: Optional[str] = None
|
||
ombre: Optional[str] = None # ombre | mi-ombre | plein_soleil
|
||
sol_type: Optional[str] = None
|
||
sol_ph: Optional[float] = None
|
||
grille_largeur: int = 6
|
||
grille_hauteur: int = 4
|
||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||
|
||
|
||
class GardenCell(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
garden_id: int = Field(foreign_key="garden.id", index=True)
|
||
col: int
|
||
row: int
|
||
libelle: Optional[str] = None
|
||
largeur_m: Optional[float] = None
|
||
hauteur_m: Optional[float] = None
|
||
etat: str = "libre" # libre | occupe
|
||
notes: Optional[str] = None
|
||
|
||
|
||
class GardenImage(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
garden_id: int = Field(foreign_key="garden.id", index=True)
|
||
filename: str
|
||
caption: Optional[str] = None
|
||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||
|
||
|
||
class Measurement(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
garden_id: int = Field(foreign_key="garden.id", index=True)
|
||
temp_air: Optional[float] = None
|
||
temp_sol: Optional[float] = None
|
||
humidite_air: Optional[float] = None
|
||
humidite_sol: Optional[float] = None
|
||
source: str = "manuel" # manuel | capteur
|
||
ts: datetime = Field(default_factory=datetime.utcnow)
|
||
```
|
||
|
||
**Step 2: Créer `backend/app/models/plant.py`**
|
||
|
||
```python
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
from sqlmodel import Field, SQLModel
|
||
|
||
|
||
class PlantVariety(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
nom_commun: str
|
||
nom_botanique: Optional[str] = None
|
||
variete: Optional[str] = None
|
||
famille: Optional[str] = None
|
||
tags: Optional[str] = None # CSV
|
||
type_plante: Optional[str] = None # legume | fruit | aromatique | fleur
|
||
besoin_eau: Optional[str] = None # faible | moyen | fort
|
||
besoin_soleil: Optional[str] = None
|
||
espacement_cm: Optional[int] = None
|
||
temp_min_c: Optional[float] = None
|
||
duree_culture_j: Optional[int] = None
|
||
profondeur_semis_cm: Optional[float] = None
|
||
sol_conseille: Optional[str] = None
|
||
semis_interieur_mois: Optional[str] = None # ex: "2,3"
|
||
semis_exterieur_mois: Optional[str] = None
|
||
repiquage_mois: Optional[str] = None
|
||
plantation_mois: Optional[str] = None
|
||
recolte_mois: Optional[str] = None
|
||
notes: Optional[str] = None
|
||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||
|
||
|
||
class PlantImage(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
variety_id: int = Field(foreign_key="plantvariety.id", index=True)
|
||
filename: str
|
||
caption: Optional[str] = None
|
||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||
```
|
||
|
||
**Step 3: Créer `backend/app/models/planting.py`**
|
||
|
||
```python
|
||
from datetime import date, datetime
|
||
from typing import Optional
|
||
from sqlmodel import Field, SQLModel
|
||
|
||
|
||
class Planting(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
garden_id: int = Field(foreign_key="garden.id", index=True)
|
||
variety_id: int = Field(foreign_key="plantvariety.id", index=True)
|
||
cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
|
||
date_semis: Optional[date] = None
|
||
date_plantation: Optional[date] = None
|
||
date_repiquage: Optional[date] = None
|
||
quantite: int = 1
|
||
statut: str = "prevu" # prevu | en_cours | termine | echoue
|
||
date_recolte_debut: Optional[date] = None
|
||
date_recolte_fin: Optional[date] = None
|
||
rendement_estime: Optional[float] = None
|
||
rendement_reel: Optional[float] = None
|
||
notes: Optional[str] = None
|
||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||
|
||
|
||
class PlantingEvent(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
planting_id: int = Field(foreign_key="planting.id", index=True)
|
||
type: str # arrosage | taille | traitement | observation | autre
|
||
note: Optional[str] = None
|
||
ts: datetime = Field(default_factory=datetime.utcnow)
|
||
```
|
||
|
||
**Step 4: Créer `backend/app/models/task.py`**
|
||
|
||
```python
|
||
from datetime import date, datetime
|
||
from typing import Optional
|
||
from sqlmodel import Field, SQLModel
|
||
|
||
|
||
class Task(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
titre: str
|
||
description: Optional[str] = None
|
||
garden_id: Optional[int] = Field(default=None, foreign_key="garden.id")
|
||
planting_id: Optional[int] = Field(default=None, foreign_key="planting.id")
|
||
priorite: str = "normale" # basse | normale | haute
|
||
echeance: Optional[date] = None
|
||
recurrence: Optional[str] = None # quotidien | hebdomadaire | mensuel
|
||
statut: str = "a_faire" # a_faire | en_cours | fait | annule
|
||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||
```
|
||
|
||
**Step 5: Créer `backend/app/models/settings.py`**
|
||
|
||
```python
|
||
from datetime import date
|
||
from typing import Optional
|
||
from sqlmodel import Field, SQLModel
|
||
|
||
|
||
class UserSettings(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
cle: str = Field(unique=True)
|
||
valeur: str
|
||
|
||
|
||
class LunarCalendarEntry(SQLModel, table=True):
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
jour: date = Field(unique=True)
|
||
phase: str # nouvelle_lune | premier_quartier | pleine_lune | dernier_quartier | croissante | decroissante
|
||
type_jour: Optional[str] = None # racine | feuille | fleur | fruit
|
||
lune_montante: Optional[bool] = None
|
||
```
|
||
|
||
**Step 6: Remplir `backend/app/models/__init__.py`**
|
||
|
||
```python
|
||
from app.models.garden import Garden, GardenCell, GardenImage, Measurement # noqa
|
||
from app.models.plant import PlantVariety, PlantImage # noqa
|
||
from app.models.planting import Planting, PlantingEvent # noqa
|
||
from app.models.task import Task # noqa
|
||
from app.models.settings import UserSettings, LunarCalendarEntry # noqa
|
||
```
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/app/models/
|
||
git commit -m "feat(backend): modèles SQLModel (10 tables)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Backend — Router jardins (TDD)
|
||
|
||
**Files:**
|
||
- Create: `backend/tests/conftest.py`
|
||
- Create: `backend/tests/test_gardens.py`
|
||
- Create: `backend/app/routers/gardens.py`
|
||
- Modify: `backend/app/main.py`
|
||
|
||
**Step 1: Créer `backend/tests/conftest.py`**
|
||
|
||
```python
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlmodel import SQLModel, create_engine, Session
|
||
from sqlmodel.pool import StaticPool
|
||
|
||
import app.models # noqa — force l'enregistrement des modèles
|
||
from app.main import app
|
||
from app.database import get_session
|
||
|
||
|
||
@pytest.fixture(name="session")
|
||
def session_fixture():
|
||
engine = create_engine(
|
||
"sqlite://",
|
||
connect_args={"check_same_thread": False},
|
||
poolclass=StaticPool,
|
||
)
|
||
SQLModel.metadata.create_all(engine)
|
||
with Session(engine) as session:
|
||
yield session
|
||
|
||
|
||
@pytest.fixture(name="client")
|
||
def client_fixture(session: Session):
|
||
def get_session_override():
|
||
yield session
|
||
|
||
app.dependency_overrides[get_session] = get_session_override
|
||
client = TestClient(app)
|
||
yield client
|
||
app.dependency_overrides.clear()
|
||
```
|
||
|
||
**Step 2: Créer `backend/tests/test_gardens.py`**
|
||
|
||
```python
|
||
def test_health(client):
|
||
r = client.get("/api/health")
|
||
assert r.status_code == 200
|
||
|
||
|
||
def test_create_garden(client):
|
||
r = client.post("/api/gardens", json={"nom": "Mon potager", "type": "plein_air"})
|
||
assert r.status_code == 201
|
||
data = r.json()
|
||
assert data["nom"] == "Mon potager"
|
||
assert data["id"] is not None
|
||
|
||
|
||
def test_list_gardens(client):
|
||
client.post("/api/gardens", json={"nom": "Jardin 1", "type": "plein_air"})
|
||
client.post("/api/gardens", json={"nom": "Jardin 2", "type": "serre"})
|
||
r = client.get("/api/gardens")
|
||
assert r.status_code == 200
|
||
assert len(r.json()) == 2
|
||
|
||
|
||
def test_get_garden(client):
|
||
r = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"})
|
||
id = r.json()["id"]
|
||
r2 = client.get(f"/api/gardens/{id}")
|
||
assert r2.status_code == 200
|
||
assert r2.json()["nom"] == "Potager"
|
||
|
||
|
||
def test_update_garden(client):
|
||
r = client.post("/api/gardens", json={"nom": "Vieux nom", "type": "plein_air"})
|
||
id = r.json()["id"]
|
||
r2 = client.put(f"/api/gardens/{id}", json={"nom": "Nouveau nom", "type": "serre"})
|
||
assert r2.status_code == 200
|
||
assert r2.json()["nom"] == "Nouveau nom"
|
||
|
||
|
||
def test_delete_garden(client):
|
||
r = client.post("/api/gardens", json={"nom": "À supprimer", "type": "plein_air"})
|
||
id = r.json()["id"]
|
||
r2 = client.delete(f"/api/gardens/{id}")
|
||
assert r2.status_code == 204
|
||
r3 = client.get(f"/api/gardens/{id}")
|
||
assert r3.status_code == 404
|
||
|
||
|
||
def test_create_measurement(client):
|
||
r = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"})
|
||
id = r.json()["id"]
|
||
r2 = client.post(
|
||
f"/api/gardens/{id}/measurements",
|
||
json={"temp_air": 22.5, "humidite_air": 60.0},
|
||
)
|
||
assert r2.status_code == 201
|
||
```
|
||
|
||
**Step 3: Lancer les tests — vérifier qu'ils échouent**
|
||
|
||
```bash
|
||
cd backend && pip install -r requirements.txt && pytest tests/test_gardens.py -v
|
||
```
|
||
|
||
Attendu : `ImportError` ou `404` (routers non créés).
|
||
|
||
**Step 4: Créer `backend/app/routers/gardens.py`**
|
||
|
||
```python
|
||
from datetime import datetime
|
||
from typing import List
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlmodel import Session, select
|
||
|
||
from app.database import get_session
|
||
from app.models.garden import Garden, GardenCell, GardenImage, Measurement
|
||
|
||
router = APIRouter(tags=["jardins"])
|
||
|
||
|
||
@router.get("/gardens", response_model=List[Garden])
|
||
def list_gardens(session: Session = Depends(get_session)):
|
||
return session.exec(select(Garden)).all()
|
||
|
||
|
||
@router.post("/gardens", response_model=Garden, status_code=status.HTTP_201_CREATED)
|
||
def create_garden(garden: Garden, session: Session = Depends(get_session)):
|
||
session.add(garden)
|
||
session.commit()
|
||
session.refresh(garden)
|
||
return garden
|
||
|
||
|
||
@router.get("/gardens/{id}", response_model=Garden)
|
||
def get_garden(id: int, session: Session = Depends(get_session)):
|
||
g = session.get(Garden, id)
|
||
if not g:
|
||
raise HTTPException(status_code=404, detail="Jardin introuvable")
|
||
return g
|
||
|
||
|
||
@router.put("/gardens/{id}", response_model=Garden)
|
||
def update_garden(id: int, data: Garden, session: Session = Depends(get_session)):
|
||
g = session.get(Garden, id)
|
||
if not g:
|
||
raise HTTPException(status_code=404, detail="Jardin introuvable")
|
||
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||
setattr(g, k, v)
|
||
g.updated_at = datetime.utcnow()
|
||
session.add(g)
|
||
session.commit()
|
||
session.refresh(g)
|
||
return g
|
||
|
||
|
||
@router.delete("/gardens/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_garden(id: int, session: Session = Depends(get_session)):
|
||
g = session.get(Garden, id)
|
||
if not g:
|
||
raise HTTPException(status_code=404, detail="Jardin introuvable")
|
||
session.delete(g)
|
||
session.commit()
|
||
|
||
|
||
@router.get("/gardens/{id}/cells", response_model=List[GardenCell])
|
||
def list_cells(id: int, session: Session = Depends(get_session)):
|
||
return session.exec(select(GardenCell).where(GardenCell.garden_id == id)).all()
|
||
|
||
|
||
@router.post("/gardens/{id}/cells", response_model=GardenCell, status_code=status.HTTP_201_CREATED)
|
||
def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_session)):
|
||
cell.garden_id = id
|
||
session.add(cell)
|
||
session.commit()
|
||
session.refresh(cell)
|
||
return cell
|
||
|
||
|
||
@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
|
||
def list_measurements(id: int, session: Session = Depends(get_session)):
|
||
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
|
||
|
||
|
||
@router.post("/gardens/{id}/measurements", response_model=Measurement, status_code=status.HTTP_201_CREATED)
|
||
def create_measurement(id: int, m: Measurement, session: Session = Depends(get_session)):
|
||
m.garden_id = id
|
||
session.add(m)
|
||
session.commit()
|
||
session.refresh(m)
|
||
return m
|
||
```
|
||
|
||
**Step 5: Créer les routers stub** pour les autres ressources (pour que `main.py` démarre) :
|
||
|
||
`backend/app/routers/varieties.py`, `plantings.py`, `tasks.py`, `settings.py`, `media.py` — chacun :
|
||
|
||
```python
|
||
from fastapi import APIRouter
|
||
router = APIRouter()
|
||
```
|
||
|
||
**Step 6: Enregistrer les routers dans `backend/app/main.py`** — ajouter après le middleware :
|
||
|
||
```python
|
||
from app.routers import gardens, varieties, plantings, tasks, settings, media
|
||
|
||
app.include_router(gardens.router, prefix="/api")
|
||
app.include_router(varieties.router, prefix="/api")
|
||
app.include_router(plantings.router, prefix="/api")
|
||
app.include_router(tasks.router, prefix="/api")
|
||
app.include_router(settings.router, prefix="/api")
|
||
app.include_router(media.router, prefix="/api")
|
||
```
|
||
|
||
Aussi ajouter le mount des uploads **après** les routers :
|
||
|
||
```python
|
||
from fastapi.staticfiles import StaticFiles
|
||
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
|
||
```
|
||
|
||
**Step 7: Lancer les tests — vérifier qu'ils passent**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_gardens.py -v
|
||
```
|
||
|
||
Attendu : tous PASS.
|
||
|
||
**Step 8: Commit**
|
||
|
||
```bash
|
||
git add backend/
|
||
git commit -m "feat(backend): CRUD jardins + tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Backend — Routers variétés, plantations, tâches (TDD)
|
||
|
||
**Files:**
|
||
- Create: `backend/tests/test_varieties.py`
|
||
- Create: `backend/tests/test_plantings.py`
|
||
- Create: `backend/tests/test_tasks.py`
|
||
- Modify: `backend/app/routers/varieties.py`
|
||
- Modify: `backend/app/routers/plantings.py`
|
||
- Modify: `backend/app/routers/tasks.py`
|
||
|
||
**Step 1: Créer `backend/tests/test_varieties.py`**
|
||
|
||
```python
|
||
def test_create_variety(client):
|
||
r = client.post("/api/varieties", json={"nom_commun": "Tomate", "famille": "Solanacées"})
|
||
assert r.status_code == 201
|
||
assert r.json()["nom_commun"] == "Tomate"
|
||
|
||
|
||
def test_list_varieties(client):
|
||
client.post("/api/varieties", json={"nom_commun": "Tomate"})
|
||
client.post("/api/varieties", json={"nom_commun": "Courgette"})
|
||
r = client.get("/api/varieties")
|
||
assert r.status_code == 200
|
||
assert len(r.json()) == 2
|
||
|
||
|
||
def test_get_variety(client):
|
||
r = client.post("/api/varieties", json={"nom_commun": "Basilic"})
|
||
id = r.json()["id"]
|
||
r2 = client.get(f"/api/varieties/{id}")
|
||
assert r2.status_code == 200
|
||
|
||
|
||
def test_delete_variety(client):
|
||
r = client.post("/api/varieties", json={"nom_commun": "Test"})
|
||
id = r.json()["id"]
|
||
r2 = client.delete(f"/api/varieties/{id}")
|
||
assert r2.status_code == 204
|
||
```
|
||
|
||
**Step 2: Créer `backend/tests/test_plantings.py`**
|
||
|
||
```python
|
||
def test_create_planting(client):
|
||
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
||
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
|
||
r = client.post("/api/plantings", json={
|
||
"garden_id": g["id"], "variety_id": v["id"], "quantite": 3
|
||
})
|
||
assert r.status_code == 201
|
||
assert r.json()["statut"] == "prevu"
|
||
|
||
|
||
def test_list_plantings(client):
|
||
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
||
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
|
||
client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]})
|
||
r = client.get("/api/plantings")
|
||
assert r.status_code == 200
|
||
assert len(r.json()) >= 1
|
||
|
||
|
||
def test_add_planting_event(client):
|
||
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
||
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
|
||
p = client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]}).json()
|
||
r = client.post(f"/api/plantings/{p['id']}/events", json={"type": "arrosage", "note": "Bien arrosé"})
|
||
assert r.status_code == 201
|
||
```
|
||
|
||
**Step 3: Créer `backend/tests/test_tasks.py`**
|
||
|
||
```python
|
||
def test_create_task(client):
|
||
r = client.post("/api/tasks", json={"titre": "Arroser les tomates", "priorite": "haute"})
|
||
assert r.status_code == 201
|
||
assert r.json()["statut"] == "a_faire"
|
||
|
||
|
||
def test_list_tasks(client):
|
||
client.post("/api/tasks", json={"titre": "Tâche 1"})
|
||
client.post("/api/tasks", json={"titre": "Tâche 2"})
|
||
r = client.get("/api/tasks")
|
||
assert r.status_code == 200
|
||
assert len(r.json()) == 2
|
||
|
||
|
||
def test_filter_tasks_by_statut(client):
|
||
client.post("/api/tasks", json={"titre": "À faire", "statut": "a_faire"})
|
||
client.post("/api/tasks", json={"titre": "Fait", "statut": "fait"})
|
||
r = client.get("/api/tasks?statut=a_faire")
|
||
assert r.status_code == 200
|
||
assert all(t["statut"] == "a_faire" for t in r.json())
|
||
|
||
|
||
def test_update_task_statut(client):
|
||
r = client.post("/api/tasks", json={"titre": "À faire"})
|
||
id = r.json()["id"]
|
||
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
|
||
assert r2.status_code == 200
|
||
assert r2.json()["statut"] == "fait"
|
||
```
|
||
|
||
**Step 4: Lancer les tests — vérifier qu'ils échouent**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_varieties.py tests/test_plantings.py tests/test_tasks.py -v
|
||
```
|
||
|
||
Attendu : `404` (routers vides).
|
||
|
||
**Step 5: Implémenter `backend/app/routers/varieties.py`**
|
||
|
||
```python
|
||
from typing import List
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlmodel import Session, select
|
||
from app.database import get_session
|
||
from app.models.plant import PlantVariety
|
||
|
||
router = APIRouter(tags=["variétés"])
|
||
|
||
|
||
@router.get("/varieties", response_model=List[PlantVariety])
|
||
def list_varieties(session: Session = Depends(get_session)):
|
||
return session.exec(select(PlantVariety)).all()
|
||
|
||
|
||
@router.post("/varieties", response_model=PlantVariety, status_code=status.HTTP_201_CREATED)
|
||
def create_variety(v: PlantVariety, session: Session = Depends(get_session)):
|
||
session.add(v)
|
||
session.commit()
|
||
session.refresh(v)
|
||
return v
|
||
|
||
|
||
@router.get("/varieties/{id}", response_model=PlantVariety)
|
||
def get_variety(id: int, session: Session = Depends(get_session)):
|
||
v = session.get(PlantVariety, id)
|
||
if not v:
|
||
raise HTTPException(status_code=404, detail="Variété introuvable")
|
||
return v
|
||
|
||
|
||
@router.put("/varieties/{id}", response_model=PlantVariety)
|
||
def update_variety(id: int, data: PlantVariety, session: Session = Depends(get_session)):
|
||
v = session.get(PlantVariety, id)
|
||
if not v:
|
||
raise HTTPException(status_code=404, detail="Variété introuvable")
|
||
for k, val in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||
setattr(v, k, val)
|
||
session.add(v)
|
||
session.commit()
|
||
session.refresh(v)
|
||
return v
|
||
|
||
|
||
@router.delete("/varieties/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_variety(id: int, session: Session = Depends(get_session)):
|
||
v = session.get(PlantVariety, id)
|
||
if not v:
|
||
raise HTTPException(status_code=404, detail="Variété introuvable")
|
||
session.delete(v)
|
||
session.commit()
|
||
```
|
||
|
||
**Step 6: Implémenter `backend/app/routers/plantings.py`**
|
||
|
||
```python
|
||
from datetime import datetime
|
||
from typing import List
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlmodel import Session, select
|
||
from app.database import get_session
|
||
from app.models.planting import Planting, PlantingEvent
|
||
|
||
router = APIRouter(tags=["plantations"])
|
||
|
||
|
||
@router.get("/plantings", response_model=List[Planting])
|
||
def list_plantings(session: Session = Depends(get_session)):
|
||
return session.exec(select(Planting)).all()
|
||
|
||
|
||
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
|
||
def create_planting(p: Planting, session: Session = Depends(get_session)):
|
||
session.add(p)
|
||
session.commit()
|
||
session.refresh(p)
|
||
return p
|
||
|
||
|
||
@router.get("/plantings/{id}", response_model=Planting)
|
||
def get_planting(id: int, session: Session = Depends(get_session)):
|
||
p = session.get(Planting, id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
||
return p
|
||
|
||
|
||
@router.put("/plantings/{id}", response_model=Planting)
|
||
def update_planting(id: int, data: Planting, session: Session = Depends(get_session)):
|
||
p = session.get(Planting, id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
||
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||
setattr(p, k, v)
|
||
p.updated_at = datetime.utcnow()
|
||
session.add(p)
|
||
session.commit()
|
||
session.refresh(p)
|
||
return p
|
||
|
||
|
||
@router.delete("/plantings/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_planting(id: int, session: Session = Depends(get_session)):
|
||
p = session.get(Planting, id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
||
session.delete(p)
|
||
session.commit()
|
||
|
||
|
||
@router.get("/plantings/{id}/events", response_model=List[PlantingEvent])
|
||
def list_events(id: int, session: Session = Depends(get_session)):
|
||
return session.exec(select(PlantingEvent).where(PlantingEvent.planting_id == id)).all()
|
||
|
||
|
||
@router.post("/plantings/{id}/events", response_model=PlantingEvent, status_code=status.HTTP_201_CREATED)
|
||
def create_event(id: int, e: PlantingEvent, session: Session = Depends(get_session)):
|
||
e.planting_id = id
|
||
session.add(e)
|
||
session.commit()
|
||
session.refresh(e)
|
||
return e
|
||
```
|
||
|
||
**Step 7: Implémenter `backend/app/routers/tasks.py`**
|
||
|
||
```python
|
||
from datetime import datetime
|
||
from typing import List, Optional
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlmodel import Session, select
|
||
from app.database import get_session
|
||
from app.models.task import Task
|
||
|
||
router = APIRouter(tags=["tâches"])
|
||
|
||
|
||
@router.get("/tasks", response_model=List[Task])
|
||
def list_tasks(
|
||
statut: Optional[str] = None,
|
||
garden_id: Optional[int] = None,
|
||
session: Session = Depends(get_session),
|
||
):
|
||
q = select(Task)
|
||
if statut:
|
||
q = q.where(Task.statut == statut)
|
||
if garden_id:
|
||
q = q.where(Task.garden_id == garden_id)
|
||
return session.exec(q).all()
|
||
|
||
|
||
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||
def create_task(t: Task, session: Session = Depends(get_session)):
|
||
session.add(t)
|
||
session.commit()
|
||
session.refresh(t)
|
||
return t
|
||
|
||
|
||
@router.get("/tasks/{id}", response_model=Task)
|
||
def get_task(id: int, session: Session = Depends(get_session)):
|
||
t = session.get(Task, id)
|
||
if not t:
|
||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||
return t
|
||
|
||
|
||
@router.put("/tasks/{id}", response_model=Task)
|
||
def update_task(id: int, data: Task, session: Session = Depends(get_session)):
|
||
t = session.get(Task, id)
|
||
if not t:
|
||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||
setattr(t, k, v)
|
||
t.updated_at = datetime.utcnow()
|
||
session.add(t)
|
||
session.commit()
|
||
session.refresh(t)
|
||
return t
|
||
|
||
|
||
@router.delete("/tasks/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_task(id: int, session: Session = Depends(get_session)):
|
||
t = session.get(Task, id)
|
||
if not t:
|
||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||
session.delete(t)
|
||
session.commit()
|
||
```
|
||
|
||
**Step 8: Lancer tous les tests**
|
||
|
||
```bash
|
||
cd backend && pytest tests/ -v
|
||
```
|
||
|
||
Attendu : tous PASS.
|
||
|
||
**Step 9: Commit**
|
||
|
||
```bash
|
||
git add backend/
|
||
git commit -m "feat(backend): CRUD variétés, plantations, tâches + tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Backend — Settings, upload media + seed
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/routers/settings.py`
|
||
- Modify: `backend/app/routers/media.py`
|
||
- Modify: `backend/app/seed.py`
|
||
|
||
**Step 1: Implémenter `backend/app/routers/settings.py`**
|
||
|
||
```python
|
||
from datetime import date
|
||
from fastapi import APIRouter, Depends
|
||
from sqlmodel import Session, select
|
||
from app.database import get_session
|
||
from app.models.settings import UserSettings, LunarCalendarEntry
|
||
|
||
router = APIRouter(tags=["réglages"])
|
||
|
||
|
||
@router.get("/settings")
|
||
def get_settings(session: Session = Depends(get_session)):
|
||
rows = session.exec(select(UserSettings)).all()
|
||
return {r.cle: r.valeur for r in rows}
|
||
|
||
|
||
@router.put("/settings")
|
||
def update_settings(data: dict, session: Session = Depends(get_session)):
|
||
for cle, valeur in data.items():
|
||
row = session.exec(select(UserSettings).where(UserSettings.cle == cle)).first()
|
||
if row:
|
||
row.valeur = str(valeur)
|
||
else:
|
||
row = UserSettings(cle=cle, valeur=str(valeur))
|
||
session.add(row)
|
||
session.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@router.get("/lunar")
|
||
def get_lunar(month: str, session: Session = Depends(get_session)):
|
||
year, m = map(int, month.split("-"))
|
||
first = date(year, m, 1)
|
||
last_m, last_y = (m + 1, year) if m < 12 else (1, year + 1)
|
||
last = date(last_y, last_m, 1)
|
||
return session.exec(
|
||
select(LunarCalendarEntry)
|
||
.where(LunarCalendarEntry.jour >= first)
|
||
.where(LunarCalendarEntry.jour < last)
|
||
).all()
|
||
```
|
||
|
||
**Step 2: Implémenter `backend/app/routers/media.py`**
|
||
|
||
```python
|
||
import os
|
||
import uuid
|
||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||
from app.config import UPLOAD_DIR
|
||
|
||
router = APIRouter(tags=["media"])
|
||
|
||
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||
|
||
|
||
@router.post("/upload")
|
||
async def upload_file(file: UploadFile = File(...)):
|
||
ext = os.path.splitext(file.filename or "")[-1].lower()
|
||
if ext not in ALLOWED_EXT:
|
||
raise HTTPException(status_code=400, detail="Format non supporté")
|
||
filename = f"{uuid.uuid4().hex}{ext}"
|
||
dest = os.path.join(UPLOAD_DIR, filename)
|
||
content = await file.read()
|
||
with open(dest, "wb") as f:
|
||
f.write(content)
|
||
return {"filename": filename, "url": f"/uploads/{filename}"}
|
||
|
||
|
||
@router.delete("/upload/{filename}", status_code=204)
|
||
def delete_file(filename: str):
|
||
path = os.path.join(UPLOAD_DIR, filename)
|
||
if os.path.exists(path):
|
||
os.remove(path)
|
||
```
|
||
|
||
**Step 3: Remplir `backend/app/seed.py`**
|
||
|
||
```python
|
||
from datetime import date
|
||
from sqlmodel import Session, select
|
||
from app.database import engine
|
||
import app.models # noqa
|
||
|
||
|
||
def run_seed():
|
||
from app.models.garden import Garden, GardenCell, Measurement
|
||
from app.models.plant import PlantVariety
|
||
from app.models.planting import Planting, PlantingEvent
|
||
from app.models.task import Task
|
||
|
||
with Session(engine) as session:
|
||
if session.exec(select(Garden)).first():
|
||
return # déjà seedé
|
||
|
||
jardin = Garden(
|
||
nom="Mon potager",
|
||
description="Potager principal plein sud",
|
||
type="plein_air",
|
||
exposition="S",
|
||
ombre="plein_soleil",
|
||
sol_type="limoneux",
|
||
grille_largeur=6,
|
||
grille_hauteur=4,
|
||
)
|
||
session.add(jardin)
|
||
session.flush()
|
||
|
||
for row in range(4):
|
||
for col in range(6):
|
||
session.add(GardenCell(
|
||
garden_id=jardin.id,
|
||
col=col, row=row,
|
||
libelle=f"{chr(65 + row)}{col + 1}",
|
||
))
|
||
|
||
session.add(Measurement(garden_id=jardin.id, temp_air=18.0, humidite_air=65.0))
|
||
|
||
tomate = PlantVariety(
|
||
nom_commun="Tomate", variete="Andine Cornue",
|
||
famille="Solanacées", type_plante="legume",
|
||
besoin_eau="fort", espacement_cm=60,
|
||
plantation_mois="4,5", recolte_mois="7,8,9",
|
||
)
|
||
courgette = PlantVariety(
|
||
nom_commun="Courgette", variete="Verte",
|
||
famille="Cucurbitacées", type_plante="legume",
|
||
besoin_eau="moyen", espacement_cm=80,
|
||
plantation_mois="5,6", recolte_mois="7,8",
|
||
)
|
||
salade = PlantVariety(
|
||
nom_commun="Laitue", variete="Batavia",
|
||
famille="Astéracées", type_plante="legume",
|
||
besoin_eau="moyen", espacement_cm=25,
|
||
)
|
||
session.add_all([tomate, courgette, salade])
|
||
session.flush()
|
||
|
||
p1 = Planting(garden_id=jardin.id, variety_id=tomate.id,
|
||
date_plantation=date(2026, 5, 1), quantite=6, statut="en_cours")
|
||
p2 = Planting(garden_id=jardin.id, variety_id=courgette.id,
|
||
date_plantation=date(2026, 5, 15), quantite=3, statut="prevu")
|
||
session.add_all([p1, p2])
|
||
session.flush()
|
||
|
||
session.add(PlantingEvent(planting_id=p1.id, type="arrosage", note="Arrosage du matin"))
|
||
|
||
session.add(Task(titre="Arroser les tomates", priorite="haute",
|
||
statut="a_faire", garden_id=jardin.id))
|
||
session.add(Task(titre="Traiter contre les pucerons", priorite="normale", statut="a_faire"))
|
||
session.add(Task(titre="Préparer le compost", priorite="basse", statut="en_cours"))
|
||
|
||
session.commit()
|
||
```
|
||
|
||
**Step 4: Lancer tous les tests une dernière fois**
|
||
|
||
```bash
|
||
cd backend && pytest tests/ -v
|
||
```
|
||
|
||
Attendu : tous PASS.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/
|
||
git commit -m "feat(backend): settings, upload media, seed données démo"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Frontend — Scaffold Vue 3 + Vite + Tailwind
|
||
|
||
**Files:**
|
||
- Create: `frontend/package.json`
|
||
- Create: `frontend/vite.config.ts`
|
||
- Create: `frontend/tailwind.config.js`
|
||
- Create: `frontend/postcss.config.js`
|
||
- Create: `frontend/tsconfig.json`
|
||
- Create: `frontend/index.html`
|
||
- Create: `frontend/src/style.css`
|
||
- Create: `frontend/src/main.ts`
|
||
- Create: `frontend/src/App.vue`
|
||
|
||
**Step 1: Créer `frontend/package.json`**
|
||
|
||
```json
|
||
{
|
||
"name": "jardin-frontend",
|
||
"version": "0.1.0",
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "vue-tsc && vite build",
|
||
"preview": "vite preview",
|
||
"lint": "vue-tsc --noEmit"
|
||
},
|
||
"dependencies": {
|
||
"vue": "^3.5.13",
|
||
"vue-router": "^4.5.0",
|
||
"pinia": "^2.3.0",
|
||
"axios": "^1.7.9"
|
||
},
|
||
"devDependencies": {
|
||
"@vitejs/plugin-vue": "^5.2.1",
|
||
"vite": "^6.0.7",
|
||
"typescript": "^5.7.3",
|
||
"vue-tsc": "^2.2.0",
|
||
"tailwindcss": "^3.4.17",
|
||
"postcss": "^8.5.1",
|
||
"autoprefixer": "^10.4.20"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Créer `frontend/vite.config.ts`**
|
||
|
||
```typescript
|
||
import { defineConfig } from 'vite'
|
||
import vue from '@vitejs/plugin-vue'
|
||
import { fileURLToPath, URL } from 'node:url'
|
||
|
||
export default defineConfig({
|
||
plugins: [vue()],
|
||
resolve: {
|
||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||
},
|
||
server: {
|
||
proxy: {
|
||
'/api': 'http://localhost:8000',
|
||
'/uploads': 'http://localhost:8000',
|
||
},
|
||
},
|
||
})
|
||
```
|
||
|
||
**Step 3: Créer `frontend/tailwind.config.js`**
|
||
|
||
```javascript
|
||
/** @type {import('tailwindcss').Config} */
|
||
export default {
|
||
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
bg: '#282828',
|
||
'bg-soft': '#3c3836',
|
||
'bg-hard': '#1d2021',
|
||
text: '#ebdbb2',
|
||
'text-muted': '#a89984',
|
||
green: '#b8bb26',
|
||
yellow: '#fabd2f',
|
||
blue: '#83a598',
|
||
orange: '#fe8019',
|
||
red: '#fb4934',
|
||
purple: '#d3869b',
|
||
aqua: '#8ec07c',
|
||
},
|
||
fontFamily: {
|
||
mono: ['"Fira Code"', '"Courier New"', 'monospace'],
|
||
},
|
||
},
|
||
},
|
||
}
|
||
```
|
||
|
||
**Step 4: Créer `frontend/postcss.config.js`**
|
||
|
||
```javascript
|
||
export default {
|
||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||
}
|
||
```
|
||
|
||
**Step 5: Créer `frontend/tsconfig.json`**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2020",
|
||
"module": "ESNext",
|
||
"moduleResolution": "bundler",
|
||
"strict": true,
|
||
"jsx": "preserve",
|
||
"lib": ["ES2020", "DOM"],
|
||
"skipLibCheck": true,
|
||
"paths": { "@/*": ["./src/*"] }
|
||
},
|
||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||
}
|
||
```
|
||
|
||
**Step 6: Créer `frontend/index.html`**
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>🌿 Jardin</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet" />
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
<script type="module" src="/src/main.ts"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
**Step 7: Créer `frontend/src/style.css`**
|
||
|
||
```css
|
||
@tailwind base;
|
||
@tailwind components;
|
||
@tailwind utilities;
|
||
|
||
body {
|
||
@apply bg-bg text-text font-mono;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-track { background: #1d2021; }
|
||
::-webkit-scrollbar-thumb { background: #504945; border-radius: 3px; }
|
||
```
|
||
|
||
**Step 8: Créer `frontend/src/main.ts`**
|
||
|
||
```typescript
|
||
import { createApp } from 'vue'
|
||
import { createPinia } from 'pinia'
|
||
import App from './App.vue'
|
||
import router from './router'
|
||
import './style.css'
|
||
|
||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||
```
|
||
|
||
**Step 9: Créer `frontend/src/App.vue`** (stub — sera remplacé en Task 9)
|
||
|
||
```vue
|
||
<template>
|
||
<RouterView />
|
||
</template>
|
||
<script setup lang="ts">
|
||
import { RouterView } from 'vue-router'
|
||
</script>
|
||
```
|
||
|
||
**Step 10: Vérifier l'install**
|
||
|
||
```bash
|
||
cd frontend && npm install && npm run build
|
||
```
|
||
|
||
Attendu : build sans erreur, dossier `dist/` créé.
|
||
|
||
**Step 11: Commit**
|
||
|
||
```bash
|
||
git add frontend/
|
||
git commit -m "feat(frontend): scaffold Vue 3 + Vite + Tailwind Gruvbox"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Frontend — API layer + Stores Pinia
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/api/client.ts`
|
||
- Create: `frontend/src/api/gardens.ts`
|
||
- Create: `frontend/src/api/varieties.ts`
|
||
- Create: `frontend/src/api/plantings.ts`
|
||
- Create: `frontend/src/api/tasks.ts`
|
||
- Create: `frontend/src/stores/gardens.ts`
|
||
- Create: `frontend/src/stores/varieties.ts`
|
||
- Create: `frontend/src/stores/plantings.ts`
|
||
- Create: `frontend/src/stores/tasks.ts`
|
||
|
||
**Step 1: Créer `frontend/src/api/client.ts`**
|
||
|
||
```typescript
|
||
import axios from 'axios'
|
||
|
||
export default axios.create({
|
||
baseURL: import.meta.env.VITE_API_URL ?? '',
|
||
})
|
||
```
|
||
|
||
**Step 2: Créer `frontend/src/api/gardens.ts`**
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export interface Garden {
|
||
id?: number
|
||
nom: string
|
||
description?: string
|
||
type: string
|
||
latitude?: number
|
||
longitude?: number
|
||
adresse?: string
|
||
exposition?: string
|
||
ombre?: string
|
||
sol_type?: string
|
||
grille_largeur: number
|
||
grille_hauteur: number
|
||
}
|
||
|
||
export interface GardenCell {
|
||
id?: number
|
||
garden_id?: number
|
||
col: number
|
||
row: number
|
||
libelle?: string
|
||
etat: string
|
||
notes?: string
|
||
}
|
||
|
||
export interface Measurement {
|
||
id?: number
|
||
garden_id?: number
|
||
temp_air?: number
|
||
temp_sol?: number
|
||
humidite_air?: number
|
||
humidite_sol?: number
|
||
source?: string
|
||
ts?: string
|
||
}
|
||
|
||
export const gardensApi = {
|
||
list: () => client.get<Garden[]>('/api/gardens').then(r => r.data),
|
||
get: (id: number) => client.get<Garden>(`/api/gardens/${id}`).then(r => r.data),
|
||
create: (g: Partial<Garden>) => client.post<Garden>('/api/gardens', g).then(r => r.data),
|
||
update: (id: number, g: Partial<Garden>) => client.put<Garden>(`/api/gardens/${id}`, g).then(r => r.data),
|
||
delete: (id: number) => client.delete(`/api/gardens/${id}`),
|
||
cells: (id: number) => client.get<GardenCell[]>(`/api/gardens/${id}/cells`).then(r => r.data),
|
||
measurements: (id: number) => client.get<Measurement[]>(`/api/gardens/${id}/measurements`).then(r => r.data),
|
||
addMeasurement: (id: number, m: Partial<Measurement>) =>
|
||
client.post<Measurement>(`/api/gardens/${id}/measurements`, m).then(r => r.data),
|
||
}
|
||
```
|
||
|
||
**Step 3: Créer `frontend/src/api/varieties.ts`**
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export interface PlantVariety {
|
||
id?: number
|
||
nom_commun: string
|
||
nom_botanique?: string
|
||
variete?: string
|
||
famille?: string
|
||
type_plante?: string
|
||
besoin_eau?: string
|
||
espacement_cm?: number
|
||
plantation_mois?: string
|
||
recolte_mois?: string
|
||
notes?: string
|
||
}
|
||
|
||
export const varietiesApi = {
|
||
list: () => client.get<PlantVariety[]>('/api/varieties').then(r => r.data),
|
||
get: (id: number) => client.get<PlantVariety>(`/api/varieties/${id}`).then(r => r.data),
|
||
create: (v: Partial<PlantVariety>) => client.post<PlantVariety>('/api/varieties', v).then(r => r.data),
|
||
update: (id: number, v: Partial<PlantVariety>) => client.put<PlantVariety>(`/api/varieties/${id}`, v).then(r => r.data),
|
||
delete: (id: number) => client.delete(`/api/varieties/${id}`),
|
||
}
|
||
```
|
||
|
||
**Step 4: Créer `frontend/src/api/plantings.ts`**
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export interface Planting {
|
||
id?: number
|
||
garden_id: number
|
||
variety_id: number
|
||
cell_id?: number
|
||
date_plantation?: string
|
||
quantite: number
|
||
statut: string
|
||
notes?: string
|
||
}
|
||
|
||
export interface PlantingEvent {
|
||
id?: number
|
||
planting_id?: number
|
||
type: string
|
||
note?: string
|
||
ts?: string
|
||
}
|
||
|
||
export const plantingsApi = {
|
||
list: () => client.get<Planting[]>('/api/plantings').then(r => r.data),
|
||
get: (id: number) => client.get<Planting>(`/api/plantings/${id}`).then(r => r.data),
|
||
create: (p: Partial<Planting>) => client.post<Planting>('/api/plantings', p).then(r => r.data),
|
||
update: (id: number, p: Partial<Planting>) => client.put<Planting>(`/api/plantings/${id}`, p).then(r => r.data),
|
||
delete: (id: number) => client.delete(`/api/plantings/${id}`),
|
||
events: (id: number) => client.get<PlantingEvent[]>(`/api/plantings/${id}/events`).then(r => r.data),
|
||
addEvent: (id: number, e: Partial<PlantingEvent>) =>
|
||
client.post<PlantingEvent>(`/api/plantings/${id}/events`, e).then(r => r.data),
|
||
}
|
||
```
|
||
|
||
**Step 5: Créer `frontend/src/api/tasks.ts`**
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export interface Task {
|
||
id?: number
|
||
titre: string
|
||
description?: string
|
||
garden_id?: number
|
||
priorite: string
|
||
echeance?: string
|
||
statut: string
|
||
}
|
||
|
||
export const tasksApi = {
|
||
list: (params?: { statut?: string; garden_id?: number }) =>
|
||
client.get<Task[]>('/api/tasks', { params }).then(r => r.data),
|
||
get: (id: number) => client.get<Task>(`/api/tasks/${id}`).then(r => r.data),
|
||
create: (t: Partial<Task>) => client.post<Task>('/api/tasks', t).then(r => r.data),
|
||
update: (id: number, t: Partial<Task>) => client.put<Task>(`/api/tasks/${id}`, t).then(r => r.data),
|
||
delete: (id: number) => client.delete(`/api/tasks/${id}`),
|
||
}
|
||
```
|
||
|
||
**Step 6: Créer `frontend/src/stores/gardens.ts`**
|
||
|
||
```typescript
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { gardensApi, type Garden } from '@/api/gardens'
|
||
|
||
export const useGardensStore = defineStore('gardens', () => {
|
||
const gardens = ref<Garden[]>([])
|
||
const loading = ref(false)
|
||
|
||
async function fetchAll() {
|
||
loading.value = true
|
||
gardens.value = await gardensApi.list()
|
||
loading.value = false
|
||
}
|
||
|
||
async function create(g: Partial<Garden>) {
|
||
const created = await gardensApi.create(g)
|
||
gardens.value.push(created)
|
||
return created
|
||
}
|
||
|
||
async function remove(id: number) {
|
||
await gardensApi.delete(id)
|
||
gardens.value = gardens.value.filter(g => g.id !== id)
|
||
}
|
||
|
||
return { gardens, loading, fetchAll, create, remove }
|
||
})
|
||
```
|
||
|
||
**Step 7: Créer `frontend/src/stores/varieties.ts`**
|
||
|
||
```typescript
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { varietiesApi, type PlantVariety } from '@/api/varieties'
|
||
|
||
export const useVarietiesStore = defineStore('varieties', () => {
|
||
const varieties = ref<PlantVariety[]>([])
|
||
const loading = ref(false)
|
||
|
||
async function fetchAll() {
|
||
loading.value = true
|
||
varieties.value = await varietiesApi.list()
|
||
loading.value = false
|
||
}
|
||
|
||
async function create(v: Partial<PlantVariety>) {
|
||
const created = await varietiesApi.create(v)
|
||
varieties.value.push(created)
|
||
return created
|
||
}
|
||
|
||
async function remove(id: number) {
|
||
await varietiesApi.delete(id)
|
||
varieties.value = varieties.value.filter(v => v.id !== id)
|
||
}
|
||
|
||
return { varieties, loading, fetchAll, create, remove }
|
||
})
|
||
```
|
||
|
||
**Step 8: Créer `frontend/src/stores/plantings.ts`**
|
||
|
||
```typescript
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { plantingsApi, type Planting } from '@/api/plantings'
|
||
|
||
export const usePlantingsStore = defineStore('plantings', () => {
|
||
const plantings = ref<Planting[]>([])
|
||
const loading = ref(false)
|
||
|
||
async function fetchAll() {
|
||
loading.value = true
|
||
plantings.value = await plantingsApi.list()
|
||
loading.value = false
|
||
}
|
||
|
||
async function create(p: Partial<Planting>) {
|
||
const created = await plantingsApi.create(p)
|
||
plantings.value.push(created)
|
||
return created
|
||
}
|
||
|
||
async function remove(id: number) {
|
||
await plantingsApi.delete(id)
|
||
plantings.value = plantings.value.filter(p => p.id !== id)
|
||
}
|
||
|
||
return { plantings, loading, fetchAll, create, remove }
|
||
})
|
||
```
|
||
|
||
**Step 9: Créer `frontend/src/stores/tasks.ts`**
|
||
|
||
```typescript
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import { tasksApi, type Task } from '@/api/tasks'
|
||
|
||
export const useTasksStore = defineStore('tasks', () => {
|
||
const tasks = ref<Task[]>([])
|
||
const loading = ref(false)
|
||
|
||
async function fetchAll(params?: { statut?: string; garden_id?: number }) {
|
||
loading.value = true
|
||
tasks.value = await tasksApi.list(params)
|
||
loading.value = false
|
||
}
|
||
|
||
async function create(t: Partial<Task>) {
|
||
const created = await tasksApi.create(t)
|
||
tasks.value.push(created)
|
||
return created
|
||
}
|
||
|
||
async function updateStatut(id: number, statut: string) {
|
||
const t = tasks.value.find(t => t.id === id)
|
||
if (!t) return
|
||
const updated = await tasksApi.update(id, { ...t, statut })
|
||
Object.assign(t, updated)
|
||
}
|
||
|
||
async function remove(id: number) {
|
||
await tasksApi.delete(id)
|
||
tasks.value = tasks.value.filter(t => t.id !== id)
|
||
}
|
||
|
||
return { tasks, loading, fetchAll, create, updateStatut, remove }
|
||
})
|
||
```
|
||
|
||
**Step 10: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/ frontend/src/stores/
|
||
git commit -m "feat(frontend): API layer + stores Pinia"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Frontend — Layout + Router
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/router/index.ts`
|
||
- Create: `frontend/src/components/AppHeader.vue`
|
||
- Create: `frontend/src/components/AppDrawer.vue`
|
||
- Modify: `frontend/src/App.vue`
|
||
|
||
**Step 1: Créer `frontend/src/router/index.ts`**
|
||
|
||
```typescript
|
||
import { createRouter, createWebHistory } from 'vue-router'
|
||
|
||
export default createRouter({
|
||
history: createWebHistory(),
|
||
routes: [
|
||
{ path: '/', component: () => import('@/views/DashboardView.vue') },
|
||
{ path: '/jardins', component: () => import('@/views/JardinsView.vue') },
|
||
{ path: '/jardins/:id', component: () => import('@/views/JardinDetailView.vue') },
|
||
{ path: '/varietes', component: () => import('@/views/VarietesView.vue') },
|
||
{ path: '/plantations', component: () => import('@/views/PlantationsView.vue') },
|
||
{ path: '/planning', component: () => import('@/views/PlanningView.vue') },
|
||
{ path: '/taches', component: () => import('@/views/TachesView.vue') },
|
||
{ path: '/lunaire', component: () => import('@/views/LunaireView.vue') },
|
||
{ path: '/reglages', component: () => import('@/views/ReglagesView.vue') },
|
||
],
|
||
})
|
||
```
|
||
|
||
**Step 2: Créer `frontend/src/components/AppHeader.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<header class="fixed top-0 left-0 right-0 z-50 bg-bg-hard border-b border-bg-soft h-14 flex items-center px-4 gap-4">
|
||
<button class="md:hidden text-text-muted hover:text-text text-xl" @click="$emit('toggle-drawer')">☰</button>
|
||
<RouterLink to="/" class="text-green font-bold text-lg tracking-wide">🌿 Jardin</RouterLink>
|
||
<nav class="hidden md:flex gap-5 ml-4">
|
||
<RouterLink
|
||
v-for="l in links" :key="l.to" :to="l.to"
|
||
class="text-text-muted hover:text-text transition-colors text-sm"
|
||
active-class="text-green font-semibold"
|
||
>{{ l.label }}</RouterLink>
|
||
</nav>
|
||
</header>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { RouterLink } from 'vue-router'
|
||
defineEmits(['toggle-drawer'])
|
||
const links = [
|
||
{ to: '/', label: 'Dashboard' },
|
||
{ to: '/jardins', label: 'Jardins' },
|
||
{ to: '/varietes', label: 'Variétés' },
|
||
{ to: '/plantations', label: 'Plantations' },
|
||
{ to: '/taches', label: 'Tâches' },
|
||
{ to: '/planning', label: 'Planning' },
|
||
{ to: '/lunaire', label: 'Lunaire' },
|
||
{ to: '/reglages', label: 'Réglages' },
|
||
]
|
||
</script>
|
||
```
|
||
|
||
**Step 3: Créer `frontend/src/components/AppDrawer.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<Transition name="slide">
|
||
<div v-if="open" class="fixed inset-0 z-40 flex md:hidden" @click.self="$emit('close')">
|
||
<nav class="bg-bg-hard w-64 h-full p-6 flex flex-col gap-1 border-r border-bg-soft shadow-2xl">
|
||
<span class="text-green font-bold text-xl mb-6">🌿 Jardin</span>
|
||
<RouterLink
|
||
v-for="l in links" :key="l.to" :to="l.to"
|
||
class="text-text-muted hover:text-text py-2 px-3 rounded-lg text-sm transition-colors"
|
||
active-class="bg-bg-soft text-green"
|
||
@click="$emit('close')"
|
||
>{{ l.label }}</RouterLink>
|
||
</nav>
|
||
</div>
|
||
</Transition>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { RouterLink } from 'vue-router'
|
||
defineProps<{ open: boolean }>()
|
||
defineEmits(['close'])
|
||
const links = [
|
||
{ to: '/', label: 'Dashboard' },
|
||
{ to: '/jardins', label: 'Jardins' },
|
||
{ to: '/varietes', label: 'Variétés' },
|
||
{ to: '/plantations', label: 'Plantations' },
|
||
{ to: '/taches', label: 'Tâches' },
|
||
{ to: '/planning', label: 'Planning' },
|
||
{ to: '/lunaire', label: 'Calendrier lunaire' },
|
||
{ to: '/reglages', label: 'Réglages' },
|
||
]
|
||
</script>
|
||
|
||
<style scoped>
|
||
.slide-enter-active, .slide-leave-active { transition: opacity 0.2s; }
|
||
.slide-enter-from, .slide-leave-to { opacity: 0; }
|
||
</style>
|
||
```
|
||
|
||
**Step 4: Remplacer `frontend/src/App.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<AppHeader @toggle-drawer="drawerOpen = !drawerOpen" />
|
||
<AppDrawer :open="drawerOpen" @close="drawerOpen = false" />
|
||
<main class="pt-14 min-h-screen">
|
||
<RouterView />
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { RouterView } from 'vue-router'
|
||
import AppHeader from '@/components/AppHeader.vue'
|
||
import AppDrawer from '@/components/AppDrawer.vue'
|
||
|
||
const drawerOpen = ref(false)
|
||
</script>
|
||
```
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/
|
||
git commit -m "feat(frontend): layout header + drawer + router"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Frontend — Vues MVP
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/views/DashboardView.vue`
|
||
- Create: `frontend/src/views/JardinsView.vue`
|
||
- Create: `frontend/src/views/JardinDetailView.vue`
|
||
- Create: `frontend/src/views/VarietesView.vue`
|
||
- Create: `frontend/src/views/PlantationsView.vue`
|
||
- Create: `frontend/src/views/TachesView.vue`
|
||
- Create: `frontend/src/views/PlanningView.vue`
|
||
- Create: `frontend/src/views/LunaireView.vue`
|
||
- Create: `frontend/src/views/ReglagesView.vue`
|
||
|
||
**Step 1: Créer `frontend/src/views/DashboardView.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="p-4 max-w-2xl mx-auto">
|
||
<h1 class="text-2xl font-bold text-green mb-6">Tableau de bord</h1>
|
||
|
||
<section class="mb-6">
|
||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Tâches à faire</h2>
|
||
<div v-if="!tasksStore.tasks.filter(t => t.statut === 'a_faire').length" class="text-text-muted text-sm">
|
||
Aucune tâche en attente.
|
||
</div>
|
||
<div
|
||
v-for="t in tasksStore.tasks.filter(t => t.statut === 'a_faire').slice(0, 5)"
|
||
:key="t.id"
|
||
class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard"
|
||
>
|
||
<span :class="{ 'text-red': t.priorite === 'haute', 'text-yellow': t.priorite === 'normale', 'text-text-muted': t.priorite === 'basse' }">●</span>
|
||
<span class="text-text text-sm flex-1">{{ t.titre }}</span>
|
||
<button class="text-xs text-green hover:underline" @click="tasksStore.updateStatut(t.id!, 'fait')">✓ Fait</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Jardins</h2>
|
||
<div
|
||
v-for="g in gardensStore.gardens"
|
||
:key="g.id"
|
||
class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard cursor-pointer hover:border-green transition-colors"
|
||
@click="router.push(`/jardins/${g.id}`)"
|
||
>
|
||
<span class="text-text font-medium">{{ g.nom }}</span>
|
||
<span class="ml-2 text-xs text-text-muted px-2 py-0.5 bg-bg rounded">{{ g.type }}</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { useGardensStore } from '@/stores/gardens'
|
||
import { useTasksStore } from '@/stores/tasks'
|
||
|
||
const router = useRouter()
|
||
const gardensStore = useGardensStore()
|
||
const tasksStore = useTasksStore()
|
||
|
||
onMounted(() => { gardensStore.fetchAll(); tasksStore.fetchAll() })
|
||
</script>
|
||
```
|
||
|
||
**Step 2: Créer `frontend/src/views/JardinsView.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="p-4 max-w-2xl mx-auto">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h1 class="text-2xl font-bold text-green">Jardins</h1>
|
||
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity"
|
||
@click="showForm = !showForm">+ Nouveau</button>
|
||
</div>
|
||
|
||
<form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
|
||
<div class="grid gap-3">
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Nom *</label>
|
||
<input v-model="form.nom" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Type</label>
|
||
<select v-model="form.type" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
|
||
<option value="plein_air">Plein air</option>
|
||
<option value="serre">Serre</option>
|
||
<option value="tunnel">Tunnel</option>
|
||
</select>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Largeur grille</label>
|
||
<input v-model.number="form.grille_largeur" type="number" min="1" max="20" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Hauteur grille</label>
|
||
<input v-model.number="form.grille_hauteur" type="number" min="1" max="20" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 mt-4">
|
||
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
|
||
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
|
||
<div v-for="g in store.gardens" :key="g.id"
|
||
class="bg-bg-soft rounded-lg p-4 mb-3 border border-bg-hard flex items-center gap-3 cursor-pointer hover:border-green transition-colors group"
|
||
@click="router.push(`/jardins/${g.id}`)">
|
||
<div class="flex-1">
|
||
<div class="text-text font-medium group-hover:text-green transition-colors">{{ g.nom }}</div>
|
||
<div class="text-text-muted text-xs mt-1">{{ g.type }} · {{ g.grille_largeur }}×{{ g.grille_hauteur }} cases</div>
|
||
</div>
|
||
<button class="text-text-muted hover:text-red text-sm px-2 py-1 rounded hover:bg-bg transition-colors"
|
||
@click.stop="store.remove(g.id!)">✕</button>
|
||
</div>
|
||
|
||
<div v-if="!store.loading && !store.gardens.length" class="text-text-muted text-sm text-center py-8">
|
||
Aucun jardin. Créez-en un !
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { useGardensStore } from '@/stores/gardens'
|
||
|
||
const router = useRouter()
|
||
const store = useGardensStore()
|
||
const showForm = ref(false)
|
||
const form = reactive({ nom: '', type: 'plein_air', grille_largeur: 6, grille_hauteur: 4 })
|
||
|
||
onMounted(() => store.fetchAll())
|
||
|
||
async function submit() {
|
||
await store.create({ ...form })
|
||
showForm.value = false
|
||
Object.assign(form, { nom: '', type: 'plein_air', grille_largeur: 6, grille_hauteur: 4 })
|
||
}
|
||
</script>
|
||
```
|
||
|
||
**Step 3: Créer `frontend/src/views/JardinDetailView.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="p-4 max-w-3xl mx-auto">
|
||
<button class="text-text-muted text-sm mb-4 hover:text-text flex items-center gap-1" @click="router.back()">← Retour</button>
|
||
|
||
<div v-if="garden">
|
||
<h1 class="text-2xl font-bold text-green mb-1">{{ garden.nom }}</h1>
|
||
<p class="text-text-muted text-sm mb-6">{{ garden.type }} · {{ garden.exposition ?? 'exposition non définie' }}</p>
|
||
|
||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">
|
||
Grille {{ garden.grille_largeur }}×{{ garden.grille_hauteur }}
|
||
</h2>
|
||
<div class="overflow-x-auto pb-2">
|
||
<div class="grid gap-1 w-max"
|
||
:style="`grid-template-columns: repeat(${garden.grille_largeur}, 52px)`">
|
||
<div
|
||
v-for="cell in displayCells" :key="`${cell.row}-${cell.col}`"
|
||
class="w-[52px] h-[52px] bg-bg-soft border border-bg-hard rounded-md flex items-center justify-center text-xs text-text-muted cursor-pointer hover:border-green transition-colors select-none"
|
||
:class="{ 'border-orange/60 bg-orange/10 text-orange': cell.etat === 'occupe' }"
|
||
:title="cell.libelle"
|
||
>
|
||
{{ cell.libelle }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-text-muted text-sm">Chargement...</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { gardensApi, type Garden, type GardenCell } from '@/api/gardens'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const garden = ref<Garden | null>(null)
|
||
const cells = ref<GardenCell[]>([])
|
||
|
||
const displayCells = computed(() => {
|
||
if (!garden.value) return []
|
||
const map = new Map(cells.value.map(c => [`${c.row}-${c.col}`, c]))
|
||
const result: GardenCell[] = []
|
||
for (let row = 0; row < garden.value.grille_hauteur; row++) {
|
||
for (let col = 0; col < garden.value.grille_largeur; col++) {
|
||
result.push(map.get(`${row}-${col}`) ?? {
|
||
col, row,
|
||
libelle: `${String.fromCharCode(65 + row)}${col + 1}`,
|
||
etat: 'libre',
|
||
})
|
||
}
|
||
}
|
||
return result
|
||
})
|
||
|
||
onMounted(async () => {
|
||
const id = Number(route.params.id)
|
||
garden.value = await gardensApi.get(id)
|
||
cells.value = await gardensApi.cells(id)
|
||
})
|
||
</script>
|
||
```
|
||
|
||
**Step 4: Créer `frontend/src/views/VarietesView.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="p-4 max-w-2xl mx-auto">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h1 class="text-2xl font-bold text-green">Variétés</h1>
|
||
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
|
||
@click="showForm = !showForm">+ Nouvelle</button>
|
||
</div>
|
||
|
||
<form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
|
||
<div class="grid gap-3">
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Nom commun *</label>
|
||
<input v-model="form.nom_commun" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Variété</label>
|
||
<input v-model="form.variete" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Famille</label>
|
||
<input v-model="form.famille" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Besoin en eau</label>
|
||
<select v-model="form.besoin_eau" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
|
||
<option value="">—</option>
|
||
<option value="faible">Faible</option>
|
||
<option value="moyen">Moyen</option>
|
||
<option value="fort">Fort</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 mt-4">
|
||
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
|
||
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
|
||
<div v-for="v in store.varieties" :key="v.id"
|
||
class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard flex items-start gap-3">
|
||
<div class="flex-1">
|
||
<div class="text-text font-medium">{{ v.nom_commun }} <span v-if="v.variete" class="text-text-muted text-xs">— {{ v.variete }}</span></div>
|
||
<div class="text-text-muted text-xs mt-1">{{ v.famille }}</div>
|
||
<div class="flex gap-2 mt-2">
|
||
<span v-if="v.besoin_eau" class="text-xs px-2 py-0.5 bg-bg rounded text-blue">💧 {{ v.besoin_eau }}</span>
|
||
<span v-if="v.espacement_cm" class="text-xs px-2 py-0.5 bg-bg rounded text-text-muted">↔ {{ v.espacement_cm }}cm</span>
|
||
</div>
|
||
</div>
|
||
<button class="text-text-muted hover:text-red text-sm px-2 py-1 rounded hover:bg-bg transition-colors"
|
||
@click="store.remove(v.id!)">✕</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { useVarietiesStore } from '@/stores/varieties'
|
||
|
||
const store = useVarietiesStore()
|
||
const showForm = ref(false)
|
||
const form = reactive({ nom_commun: '', variete: '', famille: '', besoin_eau: '' })
|
||
|
||
onMounted(() => store.fetchAll())
|
||
|
||
async function submit() {
|
||
await store.create({ ...form })
|
||
showForm.value = false
|
||
Object.assign(form, { nom_commun: '', variete: '', famille: '', besoin_eau: '' })
|
||
}
|
||
</script>
|
||
```
|
||
|
||
**Step 5: Créer `frontend/src/views/TachesView.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="p-4 max-w-2xl mx-auto">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h1 class="text-2xl font-bold text-green">Tâches</h1>
|
||
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
|
||
@click="showForm = !showForm">+ Nouvelle</button>
|
||
</div>
|
||
|
||
<form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
|
||
<div class="grid gap-3">
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Titre *</label>
|
||
<input v-model="form.titre" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Priorité</label>
|
||
<select v-model="form.priorite" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
|
||
<option value="basse">Basse</option>
|
||
<option value="normale">Normale</option>
|
||
<option value="haute">Haute</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-xs block mb-1">Échéance</label>
|
||
<input v-model="form.echeance" type="date" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 mt-4">
|
||
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
|
||
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div v-for="[groupe, label] in groupes" :key="groupe" class="mb-6">
|
||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">{{ label }}</h2>
|
||
<div v-if="!byStatut(groupe).length" class="text-text-muted text-xs pl-2">—</div>
|
||
<div v-for="t in byStatut(groupe)" :key="t.id"
|
||
class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard">
|
||
<span :class="{
|
||
'text-red': t.priorite === 'haute',
|
||
'text-yellow': t.priorite === 'normale',
|
||
'text-text-muted': t.priorite === 'basse'
|
||
}">●</span>
|
||
<span class="text-text text-sm flex-1">{{ t.titre }}</span>
|
||
<div class="flex gap-1">
|
||
<button v-if="t.statut === 'a_faire'" class="text-xs text-blue hover:underline"
|
||
@click="store.updateStatut(t.id!, 'en_cours')">→ En cours</button>
|
||
<button v-if="t.statut === 'en_cours'" class="text-xs text-green hover:underline"
|
||
@click="store.updateStatut(t.id!, 'fait')">✓ Fait</button>
|
||
<button class="text-xs text-text-muted hover:text-red ml-2" @click="store.remove(t.id!)">✕</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { useTasksStore } from '@/stores/tasks'
|
||
|
||
const store = useTasksStore()
|
||
const showForm = ref(false)
|
||
const form = reactive({ titre: '', priorite: 'normale', statut: 'a_faire', echeance: '' })
|
||
|
||
const groupes: [string, string][] = [
|
||
['a_faire', 'À faire'],
|
||
['en_cours', 'En cours'],
|
||
['fait', 'Terminé'],
|
||
]
|
||
|
||
const byStatut = (s: string) => store.tasks.filter(t => t.statut === s)
|
||
|
||
onMounted(() => store.fetchAll())
|
||
|
||
async function submit() {
|
||
await store.create({ ...form })
|
||
showForm.value = false
|
||
Object.assign(form, { titre: '', priorite: 'normale', statut: 'a_faire', echeance: '' })
|
||
}
|
||
</script>
|
||
```
|
||
|
||
**Step 6: Créer `frontend/src/views/PlantationsView.vue`**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="p-4 max-w-2xl mx-auto">
|
||
<h1 class="text-2xl font-bold text-green mb-6">Plantations</h1>
|
||
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
|
||
<div v-for="p in store.plantings" :key="p.id"
|
||
class="bg-bg-soft rounded-lg p-4 mb-3 border border-bg-hard">
|
||
<div class="flex items-start gap-3">
|
||
<div class="flex-1">
|
||
<div class="text-text font-medium">Plantation #{{ p.id }}</div>
|
||
<div class="text-text-muted text-xs mt-1">
|
||
Jardin {{ p.garden_id }} · Variété {{ p.variety_id }} · {{ p.quantite }} plant(s)
|
||
</div>
|
||
<span class="inline-block mt-2 text-xs px-2 py-0.5 rounded"
|
||
:class="{
|
||
'bg-blue/20 text-blue': p.statut === 'prevu',
|
||
'bg-green/20 text-green': p.statut === 'en_cours',
|
||
'bg-text-muted/20 text-text-muted': p.statut === 'termine',
|
||
'bg-red/20 text-red': p.statut === 'echoue',
|
||
}">{{ p.statut }}</span>
|
||
</div>
|
||
<button class="text-text-muted hover:text-red text-sm" @click="store.remove(p.id!)">✕</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="!store.loading && !store.plantings.length" class="text-text-muted text-sm text-center py-8">
|
||
Aucune plantation enregistrée.
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted } from 'vue'
|
||
import { usePlantingsStore } from '@/stores/plantings'
|
||
|
||
const store = usePlantingsStore()
|
||
onMounted(() => store.fetchAll())
|
||
</script>
|
||
```
|
||
|
||
**Step 7: Créer les vues squelettes** (`PlanningView`, `LunaireView`, `ReglagesView`)
|
||
|
||
Pour chacune, même template minimal :
|
||
|
||
```vue
|
||
<!-- PlanningView.vue -->
|
||
<template>
|
||
<div class="p-4 max-w-2xl mx-auto">
|
||
<h1 class="text-2xl font-bold text-green mb-4">Planning</h1>
|
||
<p class="text-text-muted text-sm">Vue calendrier — à venir dans la prochaine étape.</p>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
(Même structure pour `LunaireView` avec titre "Calendrier lunaire" et `ReglagesView` avec titre "Réglages")
|
||
|
||
**Step 8: Vérifier que tout tourne**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Attendu : build sans erreur TypeScript.
|
||
|
||
**Step 9: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/views/
|
||
git commit -m "feat(frontend): vues MVP — dashboard, jardins, grille, variétés, tâches, plantations"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: README + vérification finale
|
||
|
||
**Files:**
|
||
- Create: `README.md`
|
||
|
||
**Step 1: Créer `README.md`**
|
||
|
||
```markdown
|
||
# 🌿 Jardin — Application de gestion de jardins
|
||
|
||
Interface web **mobile-first** pour gérer vos jardins, cultures, tâches et calendrier lunaire.
|
||
Thème : Gruvbox Dark Seventies.
|
||
|
||
## Prérequis
|
||
|
||
- Docker + Docker Compose
|
||
|
||
## Lancement
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
docker compose up --build
|
||
```
|
||
|
||
- Application : http://localhost
|
||
- API (docs) : http://localhost:8000/docs
|
||
|
||
## Développement local
|
||
|
||
**Backend :**
|
||
```bash
|
||
cd backend
|
||
python -m venv .venv && source .venv/bin/activate
|
||
pip install -r requirements.txt
|
||
DATABASE_URL=sqlite:///./data/jardin.db UPLOAD_DIR=./data/uploads uvicorn app.main:app --reload
|
||
```
|
||
|
||
**Frontend :**
|
||
```bash
|
||
cd frontend
|
||
npm install
|
||
npm run dev # http://localhost:5173
|
||
```
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
cd backend && pytest tests/ -v
|
||
```
|
||
|
||
## Sauvegarde
|
||
|
||
La base SQLite est dans `data/jardin.db`. Copiez ce fichier pour sauvegarder.
|
||
```
|
||
|
||
**Step 2: Lancer tous les tests backend**
|
||
|
||
```bash
|
||
cd backend && pytest tests/ -v
|
||
```
|
||
|
||
Attendu : tous PASS, aucune erreur.
|
||
|
||
**Step 3: Vérifier le build frontend**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Attendu : build sans erreur.
|
||
|
||
**Step 4: Commit final**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs: README installation + guide développement"
|
||
```
|
||
|
||
---
|
||
|
||
## Résumé des commits attendus
|
||
|
||
1. `chore: scaffold projet + docker-compose`
|
||
2. `feat(backend): setup FastAPI + SQLite + config`
|
||
3. `feat(backend): modèles SQLModel (10 tables)`
|
||
4. `feat(backend): CRUD jardins + tests`
|
||
5. `feat(backend): CRUD variétés, plantations, tâches + tests`
|
||
6. `feat(backend): settings, upload media, seed données démo`
|
||
7. `feat(frontend): scaffold Vue 3 + Vite + Tailwind Gruvbox`
|
||
8. `feat(frontend): API layer + stores Pinia`
|
||
9. `feat(frontend): layout header + drawer + router`
|
||
10. `feat(frontend): vues MVP — dashboard, jardins, grille, variétés, tâches, plantations`
|
||
11. `docs: README installation + guide développement`
|