This commit is contained in:
2026-03-01 07:21:46 +01:00
parent 9db5cbf236
commit 7967f63fea
39 changed files with 3297 additions and 1646 deletions

View File

@@ -75,7 +75,9 @@
"Bash(docker compose restart:*)", "Bash(docker compose restart:*)",
"Bash(docker compose build:*)", "Bash(docker compose build:*)",
"Bash(__NEW_LINE_5f780afd9b58590d__ echo \"\")", "Bash(__NEW_LINE_5f780afd9b58590d__ echo \"\")",
"Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)" "Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)",
"Bash(npx tsc:*)",
"Bash(npx vite:*)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/home/gilles/Documents/vscode/jardin/frontend/src", "/home/gilles/Documents/vscode/jardin/frontend/src",

View File

@@ -1,3 +1,4 @@
import asyncio
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -23,15 +24,22 @@ async def lifespan(app: FastAPI):
from app.seed import run_seed from app.seed import run_seed
run_seed() run_seed()
if ENABLE_SCHEDULER: if ENABLE_SCHEDULER:
from app.services.scheduler import setup_scheduler from app.services.scheduler import setup_scheduler, backfill_station_missing_dates
setup_scheduler() setup_scheduler()
# Backfill des dates manquantes en arrière-plan (ne bloque pas le démarrage)
loop = asyncio.get_running_loop()
loop.run_in_executor(None, backfill_station_missing_dates)
yield yield
if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER: if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER:
from app.services.scheduler import scheduler from app.services.scheduler import scheduler
scheduler.shutdown(wait=False) scheduler.shutdown(wait=False)
app = FastAPI(title="Jardin API", lifespan=lifespan) app = FastAPI(
title="Jardin API",
lifespan=lifespan,
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.3/bundles/redoc.standalone.js"
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -51,6 +51,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("boutique_url", "TEXT", None), ("boutique_url", "TEXT", None),
("tarif_achat", "REAL", None), ("tarif_achat", "REAL", None),
("date_achat", "TEXT", None), ("date_achat", "TEXT", None),
("cell_ids", "TEXT", None), # JSON : liste des IDs de zones (multi-sélect)
], ],
"plantvariety": [ "plantvariety": [
# ancien nom de table → migration vers "plant" si présente # ancien nom de table → migration vers "plant" si présente

View File

@@ -1,5 +1,7 @@
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from typing import Optional from typing import List, Optional
from sqlalchemy import Column
from sqlalchemy import JSON as SA_JSON
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@@ -7,6 +9,7 @@ class PlantingCreate(SQLModel):
garden_id: int garden_id: int
variety_id: int variety_id: int
cell_id: Optional[int] = None cell_id: Optional[int] = None
cell_ids: Optional[List[int]] = None # multi-sélect zones
date_semis: Optional[date] = None date_semis: Optional[date] = None
date_plantation: Optional[date] = None date_plantation: Optional[date] = None
date_repiquage: Optional[date] = None date_repiquage: Optional[date] = None
@@ -28,6 +31,10 @@ class Planting(SQLModel, table=True):
garden_id: int = Field(foreign_key="garden.id", index=True) garden_id: int = Field(foreign_key="garden.id", index=True)
variety_id: int = Field(foreign_key="plant.id", index=True) variety_id: int = Field(foreign_key="plant.id", index=True)
cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id") cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
cell_ids: Optional[List[int]] = Field(
default=None,
sa_column=Column("cell_ids", SA_JSON, nullable=True),
)
date_semis: Optional[date] = None date_semis: Optional[date] = None
date_plantation: Optional[date] = None date_plantation: Optional[date] = None
date_repiquage: Optional[date] = None date_repiquage: Optional[date] = None

View File

@@ -115,6 +115,19 @@ def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_sessio
return cell return cell
@router.put("/gardens/{id}/cells/{cell_id}", response_model=GardenCell)
def update_cell(id: int, cell_id: int, data: GardenCell, session: Session = Depends(get_session)):
c = session.get(GardenCell, cell_id)
if not c or c.garden_id != id:
raise HTTPException(status_code=404, detail="Case introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "garden_id"}).items():
setattr(c, k, v)
session.add(c)
session.commit()
session.refresh(c)
return c
@router.get("/gardens/{id}/measurements", response_model=List[Measurement]) @router.get("/gardens/{id}/measurements", response_model=List[Measurement])
def list_measurements(id: int, session: Session = Depends(get_session)): def list_measurements(id: int, session: Session = Depends(get_session)):
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all() return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()

View File

@@ -15,7 +15,11 @@ def list_plantings(session: Session = Depends(get_session)):
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED) @router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
def create_planting(data: PlantingCreate, session: Session = Depends(get_session)): def create_planting(data: PlantingCreate, session: Session = Depends(get_session)):
p = Planting(**data.model_dump()) d = data.model_dump()
# Rétro-compatibilité : cell_id = première zone sélectionnée
if d.get("cell_ids") and not d.get("cell_id"):
d["cell_id"] = d["cell_ids"][0]
p = Planting(**d)
session.add(p) session.add(p)
session.commit() session.commit()
session.refresh(p) session.refresh(p)
@@ -35,7 +39,12 @@ def update_planting(id: int, data: PlantingCreate, session: Session = Depends(ge
p = session.get(Planting, id) p = session.get(Planting, id)
if not p: if not p:
raise HTTPException(status_code=404, detail="Plantation introuvable") raise HTTPException(status_code=404, detail="Plantation introuvable")
for k, v in data.model_dump(exclude_unset=True).items(): d = data.model_dump(exclude_unset=True)
# Rétro-compatibilité : cell_id = première zone sélectionnée
if "cell_ids" in d:
ids = d["cell_ids"] or []
d["cell_id"] = ids[0] if ids else None
for k, v in d.items():
setattr(p, k, v) setattr(p, k, v)
p.updated_at = datetime.now(timezone.utc) p.updated_at = datetime.now(timezone.utc)
session.add(p) session.add(p)

View File

@@ -90,6 +90,70 @@ def _store_open_meteo() -> None:
logger.info(f"Open-Meteo stocké : {len(rows)} jours") logger.info(f"Open-Meteo stocké : {len(rows)} jours")
def backfill_station_missing_dates(max_days_back: int = 365) -> None:
"""Remplit les dates manquantes de la station météo au démarrage.
Cherche toutes les dates sans entrée « veille » dans meteostation
depuis max_days_back jours en arrière jusqu'à hier (excl. aujourd'hui),
puis télécharge les fichiers NOAA mois par mois pour remplir les trous.
Un seul appel HTTP par mois manquant.
"""
from datetime import date, timedelta
from itertools import groupby
from app.services.station import fetch_month_summaries
from app.models.meteo import MeteoStation
from app.database import engine
from sqlmodel import Session, select
today = date.today()
start_date = today - timedelta(days=max_days_back)
# 1. Dates « veille » déjà présentes en BDD
with Session(engine) as session:
rows = session.exec(
select(MeteoStation.date_heure).where(MeteoStation.type == "veille")
).all()
existing_dates: set[str] = {dh[:10] for dh in rows}
# 2. Dates manquantes entre start_date et hier (aujourd'hui exclu)
missing: list[date] = []
cursor = start_date
while cursor < today:
if cursor.isoformat() not in existing_dates:
missing.append(cursor)
cursor += timedelta(days=1)
if not missing:
logger.info("Backfill station : aucune date manquante")
return
logger.info(f"Backfill station : {len(missing)} date(s) manquante(s) à récupérer")
# 3. Grouper par (année, mois) → 1 requête HTTP par mois
def month_key(d: date) -> tuple[int, int]:
return (d.year, d.month)
filled = 0
for (year, month), group_iter in groupby(sorted(missing), key=month_key):
month_data = fetch_month_summaries(year, month)
if not month_data:
logger.debug(f"Backfill station : pas de données NOAA pour {year}-{month:02d}")
continue
with Session(engine) as session:
for d in group_iter:
data = month_data.get(d.day)
if not data:
continue
date_heure = f"{d.isoformat()}T00:00"
if not session.get(MeteoStation, date_heure):
session.add(MeteoStation(date_heure=date_heure, type="veille", **data))
filled += 1
session.commit()
logger.info(f"Backfill station terminé : {filled} date(s) insérée(s)")
def setup_scheduler() -> None: def setup_scheduler() -> None:
"""Configure et démarre le scheduler.""" """Configure et démarre le scheduler."""
scheduler.add_job( scheduler.add_job(

View File

@@ -130,45 +130,63 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None:
return None return None
def _parse_noaa_day_line(parts: list[str]) -> dict | None:
"""Parse une ligne de données journalières du fichier NOAA WeeWX.
Format standard : day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
"""
if not parts or not parts[0].isdigit():
return None
# Format complet avec timestamps hh:mm en positions 3 et 5
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
return {
"temp_ext": _safe_float(parts[1]),
"t_max": _safe_float(parts[2]),
"t_min": _safe_float(parts[4]),
"pluie_mm": _safe_float(parts[8]),
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
}
# Fallback générique (anciens formats sans hh:mm)
return {
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
}
def fetch_month_summaries(year: int, month: int, base_url: str = STATION_URL) -> dict[int, dict]:
"""Récupère tous les résumés journaliers d'un mois depuis le fichier NOAA WeeWX.
Retourne un dict {numéro_jour: data_dict} pour chaque jour disponible du mois.
Un seul appel HTTP par mois — utilisé pour le backfill groupé.
"""
try:
url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year:04d}-{month:02d}.txt"
r = httpx.get(url, timeout=15)
r.raise_for_status()
result: dict[int, dict] = {}
for line in r.text.splitlines():
parts = line.split()
if not parts or not parts[0].isdigit():
continue
data = _parse_noaa_day_line(parts)
if data:
result[int(parts[0])] = data
return result
except Exception as e:
logger.warning(f"Station fetch_month_summaries({year}-{month:02d}) error: {e}")
return {}
def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None: def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None:
"""Récupère le résumé de la veille via le fichier NOAA mensuel de la station WeeWX. """Récupère le résumé de la veille via le fichier NOAA mensuel de la station WeeWX.
Retourne un dict avec : temp_ext (moy), t_min, t_max, pluie_mm — ou None. Retourne un dict avec : temp_ext (moy), t_min, t_max, pluie_mm — ou None.
""" """
yesterday = (datetime.now() - timedelta(days=1)).date() yesterday = (datetime.now() - timedelta(days=1)).date()
year = yesterday.strftime("%Y") month_data = fetch_month_summaries(yesterday.year, yesterday.month, base_url)
month = yesterday.strftime("%m") return month_data.get(yesterday.day)
day = yesterday.day
try:
url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year}-{month}.txt"
r = httpx.get(url, timeout=15)
r.raise_for_status()
for line in r.text.splitlines():
parts = line.split()
if not parts or not parts[0].isdigit() or int(parts[0]) != day:
continue
# Format WeeWX NOAA (fréquent) :
# day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
return {
"temp_ext": _safe_float(parts[1]),
"t_max": _safe_float(parts[2]),
"t_min": _safe_float(parts[4]),
"pluie_mm": _safe_float(parts[8]),
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
}
# Fallback générique (anciens formats)
return {
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
}
except Exception as e:
logger.warning(f"Station fetch_yesterday_summary error: {e}")
return None

View File

@@ -1,27 +1,47 @@
import os import os
from typing import List from typing import List, Optional
import httpx import httpx
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://localhost:8070") AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://ai-service:8070")
# Mapping class_name YOLO → nom commun français (partiel) # Mapping complet class_name YOLO → Infos détaillées
_NOMS_FR = { _DIAGNOSTICS = {
"Tomato___healthy": "Tomate (saine)", "Tomato___healthy": {
"Tomato___Early_blight": "Tomate (mildiou précoce)", "label": "Tomate (saine)",
"Tomato___Late_blight": "Tomate (mildiou tardif)", "conseil": "Votre plant est en pleine forme. Pensez au paillage pour garder l'humidité.",
"Pepper__bell___healthy": "Poivron (sain)", "actions": ["Pailler le pied", "Vérifier les gourmands"]
"Apple___healthy": "Pommier (sain)", },
"Potato___healthy": "Pomme de terre (saine)", "Tomato___Early_blight": {
"Grape___healthy": "Vigne (saine)", "label": "Tomate (Alternariose)",
"Corn_(maize)___healthy": "Maïs (sain)", "conseil": "Champignon fréquent. Retirez les feuilles basses touchées et évitez de mouiller le feuillage.",
"Strawberry___healthy": "Fraisier (sain)", "actions": ["Retirer feuilles infectées", "Traitement bouillie bordelaise"]
"Peach___healthy": "Pêcher (sain)", },
"Tomato___Late_blight": {
"label": "Tomate (Mildiou)",
"conseil": "Urgent : Le mildiou se propage vite avec l'humidité. Coupez les parties atteintes immédiatement.",
"actions": ["Couper parties infectées", "Traitement purin de prêle", "Abriter de la pluie"]
},
"Pepper__bell___healthy": {
"label": "Poivron (sain)",
"conseil": "Le poivron aime la chaleur et un sol riche.",
"actions": ["Apport de compost", "Arrosage régulier"]
},
"Potato___healthy": {
"label": "Pomme de terre (saine)",
"conseil": "Pensez à butter les pieds pour favoriser la production de tubercules.",
"actions": ["Butter les pieds"]
},
"Grape___healthy": {
"label": "Vigne (saine)",
"conseil": "Surveillez l'apparition d'oïdium si le temps est chaud et humide.",
"actions": ["Taille en vert", "Vérifier sous les feuilles"]
},
} }
async def identify(image_bytes: bytes) -> List[dict]: async def identify(image_bytes: bytes) -> List[dict]:
"""Appelle l'ai-service interne et retourne les détections YOLO.""" """Appelle l'ai-service interne et retourne les détections YOLO avec diagnostics."""
try: try:
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post( resp = await client.post(
@@ -36,10 +56,18 @@ async def identify(image_bytes: bytes) -> List[dict]:
results = [] results = []
for det in data[:3]: for det in data[:3]:
cls = det.get("class_name", "") cls = det.get("class_name", "")
diag = _DIAGNOSTICS.get(cls, {
"label": cls.replace("___", "").replace("_", " "),
"conseil": "Pas de diagnostic spécifique disponible pour cette espèce.",
"actions": []
})
results.append({ results.append({
"species": cls.replace("___", "").replace("_", " "), "species": cls,
"common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")), "common_name": diag["label"],
"confidence": det.get("confidence", 0.0), "confidence": det.get("confidence", 0.0),
"conseil": diag["conseil"],
"actions": diag["actions"],
"image_url": "", "image_url": "",
}) })
return results return results

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🌿 Jardin</title> <title>🌿 Jardin</title>
<meta name="theme-color" content="#282828" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="mask-icon" href="/favicon.svg" color="#282828" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <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" /> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet" />
</head> </head>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#282828"/>
<text y=".9em" font-size="70" x="15">🌿</text>
</svg>

After

Width:  |  Height:  |  Size: 179 B

View File

@@ -9,6 +9,9 @@
<span>Disk {{ debugDiskLabel }}</span> <span>Disk {{ debugDiskLabel }}</span>
</div> </div>
<!-- Notifications toast globales -->
<ToastNotification />
<!-- Mobile: header + drawer --> <!-- Mobile: header + drawer -->
<AppHeader class="lg:hidden" @toggle-drawer="drawerOpen = !drawerOpen" /> <AppHeader class="lg:hidden" @toggle-drawer="drawerOpen = !drawerOpen" />
<AppDrawer :open="drawerOpen" @close="drawerOpen = false" /> <AppDrawer :open="drawerOpen" @close="drawerOpen = false" />
@@ -38,8 +41,12 @@
</aside> </aside>
<!-- Main content --> <!-- Main content -->
<main class="pt-14 lg:pt-0 lg:pl-60 min-h-screen w-full bg-bg" style="font-size: var(--ui-font-size, 14px)"> <main class="pt-14 lg:pt-0 lg:pl-60 min-h-screen w-full bg-bg">
<RouterView /> <router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main> </main>
</div> </div>
</template> </template>
@@ -49,6 +56,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, RouterView } from 'vue-router' import { RouterLink, RouterView } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppDrawer from '@/components/AppDrawer.vue' import AppDrawer from '@/components/AppDrawer.vue'
import ToastNotification from '@/components/ToastNotification.vue'
import { meteoApi } from '@/api/meteo' import { meteoApi } from '@/api/meteo'
import { settingsApi, type DebugSystemStats } from '@/api/settings' import { settingsApi, type DebugSystemStats } from '@/api/settings'
import { applyUiSizesToRoot } from '@/utils/uiSizeDefaults' import { applyUiSizesToRoot } from '@/utils/uiSizeDefaults'

View File

@@ -1,5 +1,37 @@
import axios from 'axios' import axios, { type AxiosError } from 'axios'
import { useToast } from '@/composables/useToast'
export default axios.create({ const client = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '', baseURL: import.meta.env.VITE_API_URL ?? '',
}) })
client.interceptors.response.use(
response => response,
(error: AxiosError<{ detail?: string; message?: string }>) => {
const { error: showError } = useToast()
if (error.response) {
const status = error.response.status
const detail = error.response.data?.detail ?? error.response.data?.message
if (status === 422) {
const msg = Array.isArray(detail)
? (detail as Array<{ msg?: string }>).map(d => d.msg ?? d).join(', ')
: (detail ?? 'Vérifiez les champs du formulaire')
showError(`Données invalides : ${msg}`)
} else if (status === 404) {
showError('Ressource introuvable')
} else if (status >= 500) {
showError(`Erreur serveur (${status}) — réessayez dans un instant`)
} else if (status !== 401 && status !== 403) {
showError(String(detail ?? `Erreur ${status}`))
}
} else if (error.request) {
showError('Serveur inaccessible — vérifiez votre connexion réseau')
}
return Promise.reject(error)
},
)
export default client

View File

@@ -56,6 +56,10 @@ export const gardensApi = {
}, },
delete: (id: number) => client.delete(`/api/gardens/${id}`), delete: (id: number) => client.delete(`/api/gardens/${id}`),
cells: (id: number) => client.get<GardenCell[]>(`/api/gardens/${id}/cells`).then(r => r.data), cells: (id: number) => client.get<GardenCell[]>(`/api/gardens/${id}/cells`).then(r => r.data),
createCell: (id: number, cell: Partial<GardenCell>) =>
client.post<GardenCell>(`/api/gardens/${id}/cells`, cell).then(r => r.data),
updateCell: (id: number, cellId: number, cell: Partial<GardenCell>) =>
client.put<GardenCell>(`/api/gardens/${id}/cells/${cellId}`, cell).then(r => r.data),
measurements: (id: number) => client.get<Measurement[]>(`/api/gardens/${id}/measurements`).then(r => r.data), measurements: (id: number) => client.get<Measurement[]>(`/api/gardens/${id}/measurements`).then(r => r.data),
addMeasurement: (id: number, m: Partial<Measurement>) => addMeasurement: (id: number, m: Partial<Measurement>) =>
client.post<Measurement>(`/api/gardens/${id}/measurements`, m).then(r => r.data), client.post<Measurement>(`/api/gardens/${id}/measurements`, m).then(r => r.data),

View File

@@ -0,0 +1,25 @@
import client from './client'
export interface DiagnosticResult {
species: string
common_name: string
confidence: number
conseil: string
actions: string[]
image_url?: string
}
export interface IdentifyResponse {
source: 'plantnet' | 'yolo' | 'cache'
results: DiagnosticResult[]
}
export const identifyApi = {
identify: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return client.post<IdentifyResponse>('/api/identify', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data)
}
}

View File

@@ -5,6 +5,7 @@ export interface Planting {
garden_id: number garden_id: number
variety_id: number variety_id: number
cell_id?: number cell_id?: number
cell_ids?: number[] // multi-sélect zones
date_plantation?: string date_plantation?: string
quantite: number quantite: number
statut: string statut: string

View File

@@ -0,0 +1,72 @@
<template>
<div v-if="open" class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" @click.self="$emit('close')">
<div class="card-jardin w-full max-w-md overflow-hidden animate-fade-in border-green/30">
<div class="flex items-center justify-between mb-4 border-b border-bg-hard pb-3">
<h3 class="text-green font-bold flex items-center gap-2">
<span>🔬</span> Diagnostic IA
</h3>
<button @click="$emit('close')" class="text-text-muted hover:text-red transition-colors"></button>
</div>
<div v-if="loading" class="py-12 flex flex-col items-center gap-4">
<div class="w-12 h-12 border-4 border-green/20 border-t-green rounded-full animate-spin"></div>
<p class="text-text-muted text-sm animate-pulse italic">Analyse de l'image en cours...</p>
</div>
<div v-else-if="result" class="space-y-6">
<!-- En-tête du résultat -->
<div class="flex items-center gap-4 bg-bg-hard p-3 rounded-xl">
<div class="text-3xl">🌿</div>
<div>
<div class="text-text font-bold text-lg">{{ result.common_name }}</div>
<div class="text-[10px] text-text-muted uppercase tracking-widest font-bold">Confiance : {{ (result.confidence * 100).toFixed(1) }}%</div>
</div>
</div>
<!-- Conseil -->
<div class="space-y-2">
<div class="text-[10px] font-bold text-green uppercase tracking-widest">Conseil de l'expert</div>
<p class="text-sm text-text-muted leading-relaxed bg-green/5 p-3 rounded-lg border border-green/10">
{{ result.conseil }}
</p>
</div>
<!-- Actions suggérées -->
<div v-if="result.actions?.length" class="space-y-2">
<div class="text-[10px] font-bold text-orange uppercase tracking-widest">Actions recommandées</div>
<div class="flex flex-wrap gap-2">
<span v-for="action in result.actions" :key="action" class="badge badge-orange py-1.5 px-3">
{{ action }}
</span>
</div>
</div>
<button @click="$emit('close')" class="btn-primary w-full mt-4">Compris, merci !</button>
</div>
<div v-else class="py-8 text-center text-text-muted">
Impossible d'analyser l'image. Réessayez avec une photo plus nette.
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
open: boolean
loading: boolean
result: any | null
}>()
defineEmits(['close'])
</script>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<Teleport to="body">
<div class="fixed bottom-4 right-4 z-[200] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto flex items-start gap-3 rounded-xl px-4 py-3 shadow-2xl border text-sm font-medium cursor-pointer"
:class="toastClass(toast.type)"
@click="remove(toast.id)"
>
<span class="text-base leading-none mt-0.5 shrink-0">{{ toastIcon(toast.type) }}</span>
<span class="flex-1 leading-snug">{{ toast.message }}</span>
<button
class="ml-1 opacity-50 hover:opacity-100 transition-opacity shrink-0 text-xs"
@click.stop="remove(toast.id)"
></button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
const { toasts, remove } = useToast()
function toastClass(type: string): string {
const map: Record<string, string> = {
success: 'bg-[#3c3836] border-[#b8bb26]/50 text-[#b8bb26]',
error: 'bg-[#3c3836] border-[#fb4934]/50 text-[#fb4934]',
warning: 'bg-[#3c3836] border-[#fabd2f]/50 text-[#fabd2f]',
info: 'bg-[#3c3836] border-[#83a598]/50 text-[#83a598]',
}
return map[type] ?? 'bg-[#3c3836] border-[#a89984]/40 text-[#ebdbb2]'
}
function toastIcon(type: string): string {
const map: Record<string, string> = {
success: '✓',
error: '✕',
warning: '⚠',
info: '',
}
return map[type] ?? ''
}
</script>
<style scoped>
.toast-enter-active { transition: all 0.25s ease-out; }
.toast-leave-active { transition: all 0.2s ease-in; }
.toast-enter-from { opacity: 0; transform: translateX(30px); }
.toast-leave-to { opacity: 0; transform: translateX(30px); }
.toast-move { transition: transform 0.2s ease; }
</style>

View File

@@ -0,0 +1,38 @@
import { reactive } from 'vue'
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export interface Toast {
id: number
type: ToastType
message: string
duration: number
}
const toasts = reactive<Toast[]>([])
let nextId = 1
function add(message: string, type: ToastType = 'info', duration = 4000): number {
const id = nextId++
toasts.push({ id, type, message, duration })
if (duration > 0) {
setTimeout(() => remove(id), duration)
}
return id
}
function remove(id: number) {
const index = toasts.findIndex(t => t.id === id)
if (index !== -1) toasts.splice(index, 1)
}
export function useToast() {
return {
toasts,
success: (msg: string, duration?: number) => add(msg, 'success', duration ?? 4000),
error: (msg: string, duration?: number) => add(msg, 'error', duration ?? 6000),
warning: (msg: string, duration?: number) => add(msg, 'warning', duration ?? 5000),
info: (msg: string, duration?: number) => add(msg, 'info', duration ?? 4000),
remove,
}
}

View File

@@ -3,5 +3,8 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './style.css' import './style.css'
import { registerSW } from 'virtual:pwa-register'
registerSW({ immediate: true })
createApp(App).use(createPinia()).use(router).mount('#app') createApp(App).use(createPinia()).use(router).mount('#app')

View File

@@ -2,13 +2,56 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { @layer base {
@apply bg-bg text-text font-mono; html {
min-height: 100vh; font-size: var(--ui-font-size, 14px);
}
body {
@apply bg-bg text-text font-mono selection:bg-yellow/30;
min-height: 100vh;
}
} }
@layer components {
/* Cartes avec effet 70s */
.card-jardin {
@apply bg-bg-soft border border-bg-hard rounded-xl p-4 shadow-sm
transition-all duration-300 hover:shadow-lg hover:border-text-muted/30;
}
/* Boutons stylisés */
.btn-primary {
@apply bg-yellow text-bg px-4 py-2 rounded-lg font-bold text-sm
transition-transform active:scale-95 hover:opacity-90;
}
.btn-outline {
@apply border border-bg-hard text-text-muted px-4 py-2 rounded-lg text-sm
hover:bg-bg-hard hover:text-text transition-all;
}
/* Badges colorés */
.badge {
@apply px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider;
}
.badge-green { @apply bg-green/20 text-green; }
.badge-yellow { @apply bg-yellow/20 text-yellow; }
.badge-blue { @apply bg-blue/20 text-blue; }
.badge-red { @apply bg-red/20 text-red; }
.badge-orange { @apply bg-orange/20 text-orange; }
}
/* Transitions de pages */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-enter-from { opacity: 0; transform: translateY(5px); }
.fade-leave-to { opacity: 0; transform: translateY(-5px); }
* { box-sizing: border-box; } * { box-sizing: border-box; }
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #1d2021; } ::-webkit-scrollbar-track { background: #1d2021; }
::-webkit-scrollbar-thumb { background: #504945; border-radius: 3px; } ::-webkit-scrollbar-thumb { background: #504945; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #665c54; }

View File

@@ -3,6 +3,8 @@ export const UI_SIZE_DEFAULTS: Record<string, number> = {
ui_menu_font_size: 13, ui_menu_font_size: 13,
ui_menu_icon_size: 18, ui_menu_icon_size: 18,
ui_thumb_size: 96, ui_thumb_size: 96,
ui_weather_icon_size: 48,
ui_dashboard_icon_size: 24,
} }
export function applyUiSizesToRoot(data: Record<string, string | number>): void { export function applyUiSizesToRoot(data: Record<string, string | number>): void {

View File

@@ -1,204 +1,200 @@
<template> <template>
<div class="p-4 max-w-4xl mx-auto"> <div class="p-4 max-w-[1800px] mx-auto space-y-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-yellow">💡 Astuces</h1> <div>
<button @click="openCreate" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"> <h1 class="text-3xl font-bold text-yellow tracking-tight">Astuces & Conseils</h1>
+ Ajouter <p class="text-text-muted text-xs mt-1">Bibliothèque de savoir-faire pour votre potager.</p>
</div>
<button @click="openCreate" class="btn-primary !bg-yellow !text-bg flex items-center gap-2 shadow-lg hover:shadow-yellow/20 transition-all">
<span class="text-lg leading-none">+</span> Ajouter
</button> </button>
</div> </div>
<div class="flex flex-wrap items-center gap-2 mb-4"> <!-- Filtres optimisés -->
<div class="flex flex-wrap items-center gap-3 bg-bg-soft/30 p-2 rounded-2xl border border-bg-soft shadow-inner text-xs">
<select <select
v-model="filterCategorie" v-model="filterCategorie"
class="bg-bg border border-bg-hard rounded px-3 py-1 text-text text-xs outline-none focus:border-yellow" class="bg-bg border border-bg-soft rounded-xl px-4 py-2 text-text text-xs outline-none focus:border-yellow transition-all appearance-none cursor-pointer"
> >
<option value="">Toutes catégories</option> <option value="">Toutes catégories</option>
<option value="plante">Plante</option> <option value="plante">🌱 Plante</option>
<option value="jardin">Jardin</option> <option value="jardin">🏡 Jardin</option>
<option value="tache">Tâche</option> <option value="tache"> Tâche</option>
<option value="general">Général</option> <option value="general">📖 Général</option>
<option value="ravageur">Ravageur</option> <option value="ravageur">🐛 Ravageur</option>
<option value="maladie">Maladie</option> <option value="maladie">🍄 Maladie</option>
</select> </select>
<input <div class="relative flex-1 min-w-[200px]">
v-model="filterTag" <span class="absolute left-3 top-1/2 -translate-y-1/2 opacity-40">🔍</span>
placeholder="Filtrer par tag..." <input
class="bg-bg border border-bg-hard rounded px-3 py-1 text-text text-xs outline-none focus:border-yellow w-44" v-model="filterTag"
/> placeholder="Filtrer par mot-clé..."
class="w-full bg-bg border border-bg-soft rounded-xl pl-9 pr-4 py-2 text-text text-xs outline-none focus:border-yellow transition-all"
/>
</div>
<button <button
@click="filterMoisActuel = !filterMoisActuel" @click="filterMoisActuel = !filterMoisActuel"
:class="[ :class="[
'px-3 py-1 rounded-full text-xs font-medium transition-colors border', 'px-4 py-2 rounded-xl text-xs font-bold transition-all border uppercase tracking-widest',
filterMoisActuel ? 'bg-green/20 text-green border-green/40' : 'border-bg-hard text-text-muted', filterMoisActuel ? 'bg-green/20 text-green border-green/40 shadow-lg' : 'border-bg-soft text-text-muted hover:text-text',
]" ]"
> >
📅 Ce mois 📅 Ce mois
</button> </button>
<button @click="refresh" class="text-xs text-text-muted hover:text-text underline ml-auto">Rafraîchir</button> <button @click="refresh" class="p-2 text-text-muted hover:text-yellow transition-colors" title="Actualiser">
🔄
</button>
</div> </div>
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div> <div v-if="store.loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-4">
<div v-else-if="!store.astuces.length" class="text-text-muted text-sm py-6">Aucune astuce pour ce filtre.</div> <div v-for="i in 6" :key="i" class="card-jardin h-48 animate-pulse opacity-20"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div v-else-if="!store.astuces.length" class="card-jardin text-center py-16 opacity-50 border-dashed border-2">
<div class="text-4xl mb-4">💡</div>
<p class="text-text-muted text-sm uppercase font-black tracking-widest">Aucun conseil trouvé</p>
</div>
<!-- Grille multi-colonnes -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
<div <div
v-for="a in store.astuces" v-for="a in store.astuces"
:key="a.id" :key="a.id"
class="bg-bg-soft rounded-xl p-4 border border-bg-hard" class="card-jardin group flex flex-col h-full hover:border-yellow/40 transition-all shadow-sm hover:shadow-xl !p-4"
> >
<div class="flex items-start justify-between gap-2 mb-2"> <div class="flex items-start justify-between gap-4 mb-3">
<h2 class="text-text font-semibold leading-tight">{{ a.titre }}</h2> <div class="min-w-0">
<div class="flex gap-2 shrink-0"> <h2 class="text-text font-bold text-base group-hover:text-yellow transition-colors leading-tight truncate" :title="a.titre">{{ a.titre }}</h2>
<button @click="openEdit(a)" class="text-yellow text-xs hover:underline">Édit.</button> <div class="flex items-center gap-2 mt-1.5">
<button @click="removeAstuce(a.id)" class="text-red text-xs hover:underline">Suppr.</button> <span v-if="a.categorie" class="badge badge-yellow !text-[8px] !px-1.5 !py-0.5">{{ a.categorie }}</span>
<span v-if="parseMois(a.mois).length" class="text-[8px] font-bold text-green uppercase tracking-widest bg-green/5 px-1.5 py-0.5 rounded border border-green/10">
📅 {{ parseMois(a.mois).join(', ') }}
</span>
</div>
</div>
<div class="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click="openEdit(a)" class="p-1 text-text-muted hover:text-yellow transition-transform hover:scale-110"></button>
<button @click="removeAstuce(a.id)" class="p-1 text-text-muted hover:text-red transition-transform hover:scale-110"></button>
</div> </div>
</div> </div>
<p class="text-text-muted text-sm whitespace-pre-line">{{ a.contenu }}</p> <p class="text-text-muted text-xs leading-relaxed whitespace-pre-line mb-4 flex-1 line-clamp-4 group-hover:line-clamp-none transition-all">
{{ a.contenu }}
</p>
<div v-if="parseMediaUrls(a.photos).length" class="mt-3 grid grid-cols-3 gap-2"> <!-- Médias compacts -->
<img <div v-if="parseMediaUrls(a.photos).length" class="mb-4 grid grid-cols-3 gap-1.5">
v-for="(url, idx) in parseMediaUrls(a.photos)" <div
:key="`astuce-photo-${a.id}-${idx}`" v-for="(url, idx) in parseMediaUrls(a.photos).slice(0, 3)"
:src="url" :key="idx"
alt="photo astuce" class="relative aspect-square rounded-lg overflow-hidden border border-bg-hard shadow-sm"
class="w-full h-20 object-cover rounded-md border border-bg-hard" >
/> <img :src="url" class="w-full h-full object-cover hover:scale-110 transition-transform cursor-pointer" />
<div v-if="idx === 2 && parseMediaUrls(a.photos).length > 3" class="absolute inset-0 bg-black/60 flex items-center justify-center text-[10px] font-black text-white pointer-events-none">
+{{ parseMediaUrls(a.photos).length - 3 }}
</div>
</div>
</div> </div>
<div v-if="parseMediaUrls(a.videos).length" class="mt-3 space-y-2"> <div v-if="parseMediaUrls(a.videos).length" class="mb-4">
<video <div class="relative aspect-video rounded-xl overflow-hidden border border-bg-hard bg-black/20 group/vid">
v-for="(url, idx) in parseMediaUrls(a.videos)" <video :src="parseMediaUrls(a.videos)[0]" class="w-full h-full object-cover" />
:key="`astuce-video-${a.id}-${idx}`" <div class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-100 group-hover/vid:bg-black/20 transition-all">
:src="url" <span class="text-2xl">🎬</span>
controls </div>
muted </div>
class="w-full rounded-md border border-bg-hard bg-black/40 max-h-52"
/>
</div> </div>
<div class="mt-3 flex flex-wrap gap-1"> <!-- Tags -->
<span v-if="a.categorie" class="text-[11px] bg-yellow/15 text-yellow rounded-full px-2 py-0.5">{{ a.categorie }}</span> <div class="flex flex-wrap items-center gap-1.5 pt-3 border-t border-bg-hard/50">
<span v-for="t in parseTags(a.tags)" :key="`${a.id}-t-${t}`" class="text-[11px] bg-blue/15 text-blue rounded-full px-2 py-0.5">#{{ t }}</span> <span v-for="t in parseTags(a.tags)" :key="t" class="text-[9px] font-black uppercase tracking-tighter text-blue bg-blue/5 px-1.5 py-0.5 rounded border border-blue/10">
<span v-if="parseMois(a.mois).length" class="text-[11px] bg-green/15 text-green rounded-full px-2 py-0.5">mois: {{ parseMois(a.mois).join(',') }}</span> #{{ t }}
</span>
</div> </div>
</div> </div>
</div> </div>
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm"> <!-- Modal Formulaire -->
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-lg border border-bg-soft"> <div v-if="showForm" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="closeForm">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier astuce' : 'Nouvelle astuce' }}</h2> <div class="bg-bg-hard rounded-3xl p-8 w-full max-w-2xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<form @submit.prevent="submitAstuce" class="flex flex-col gap-3"> <div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
<input <h2 class="text-text font-bold text-xl uppercase tracking-tighter">{{ editId ? 'Modifier l\'astuce' : 'Nouvelle pépite' }}</h2>
v-model="form.titre" <button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-xl"></button>
placeholder="Titre *" </div>
required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow"
/>
<textarea <form @submit.prevent="submitAstuce" class="space-y-4">
v-model="form.contenu" <div>
placeholder="Contenu *" <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Titre accrocheur *</label>
required <input v-model="form.titre" required placeholder="Ex: Mieux gérer le mildiou..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow resize-none h-28" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none transition-all shadow-inner" />
/>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<select
v-model="form.categorie"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow"
>
<option value="">Catégorie</option>
<option value="plante">Plante</option>
<option value="jardin">Jardin</option>
<option value="tache">Tâche</option>
<option value="general">Général</option>
<option value="ravageur">Ravageur</option>
<option value="maladie">Maladie</option>
</select>
<input
v-model="form.source"
placeholder="Source"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow"
/>
</div> </div>
<input <div>
v-model="form.tagsInput" <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Corps du texte *</label>
placeholder="Tags (ex: tomate, semis, mildiou)" <textarea v-model="form.contenu" required rows="1" placeholder="Partagez votre savoir-faire..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" @input="autoResize"
/> 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 transition-all shadow-inner overflow-hidden" />
</div>
<input <div class="grid grid-cols-2 gap-4">
v-model="form.moisInput" <div>
placeholder="Mois (ex: 3,4,5)" <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Catégorie</label>
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" <select v-model="form.categorie" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-xs outline-none focus:border-yellow">
/> <option value=""> Choisir </option>
<option value="plante">🌱 Plante</option>
<option value="jardin">🏡 Jardin</option>
<option value="tache"> Tâche</option>
<option value="general">📖 Général</option>
<option value="ravageur">🐛 Ravageur</option>
<option value="maladie">🍄 Maladie</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Source (URL/Nom)</label>
<input v-model="form.source" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-xs outline-none focus:border-yellow shadow-inner" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-4">
<label class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm cursor-pointer text-center hover:border-yellow"> <div>
{{ uploadingPhotos ? 'Upload photos...' : 'Ajouter photo(s)' }} <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Tags (virgule)</label>
<input <input v-model="form.tagsInput" placeholder="tomate, mildiou..."
type="file" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-xs outline-none focus:border-yellow shadow-inner" />
accept="image/*" </div>
multiple <div>
class="hidden" <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Mois (virgule)</label>
@change="uploadFiles($event, 'photo')" <input v-model="form.moisInput" placeholder="3, 4, 5..."
/> class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-xs outline-none focus:border-yellow shadow-inner" />
</div>
</div>
<div class="grid grid-cols-2 gap-4 pt-2">
<label class="btn-outline border-dashed border-bg-soft flex items-center justify-center gap-2 py-3 cursor-pointer hover:border-yellow hover:text-yellow text-[10px] font-black uppercase transition-all">
{{ uploadingPhotos ? '...' : '📸 Photos' }}
<input type="file" accept="image/*" multiple class="hidden" @change="uploadFiles($event, 'photo')" />
</label> </label>
<label class="btn-outline border-dashed border-bg-soft flex items-center justify-center gap-2 py-3 cursor-pointer hover:border-aqua hover:text-aqua text-[10px] font-black uppercase transition-all">
<label class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm cursor-pointer text-center hover:border-yellow"> {{ uploadingVideos ? '...' : '🎬 Vidéos' }}
{{ uploadingVideos ? 'Upload vidéos...' : 'Ajouter vidéo(s)' }} <input type="file" accept="video/*" multiple class="hidden" @change="uploadFiles($event, 'video')" />
<input
type="file"
accept="video/*"
multiple
class="hidden"
@change="uploadFiles($event, 'video')"
/>
</label> </label>
</div> </div>
<div v-if="form.photos.length" class="bg-bg border border-bg-soft rounded-lg p-2"> <div v-if="form.photos.length || form.videos.length" class="bg-bg-soft/30 rounded-2xl p-3 border border-bg-soft max-h-40 overflow-y-auto space-y-3 shadow-inner">
<div class="text-xs text-text-muted mb-1">Photos jointes</div> <div v-if="form.photos.length" class="grid grid-cols-6 gap-2">
<div class="grid grid-cols-3 gap-2"> <div v-for="(url, idx) in form.photos" :key="idx" class="relative group aspect-square">
<div v-for="(url, idx) in form.photos" :key="`form-photo-${idx}`" class="relative group"> <img :src="url" class="w-full h-full object-cover rounded-lg border border-bg-hard" />
<img :src="url" alt="photo astuce" class="w-full h-16 object-cover rounded border border-bg-hard" /> <button type="button" @click="removeMedia('photo', idx)" class="absolute top-1 right-1 bg-red text-white text-[8px] w-4 h-4 rounded-full flex items-center justify-center shadow-lg"></button>
<button
type="button"
class="absolute top-1 right-1 hidden group-hover:block bg-red/80 text-white text-[10px] rounded px-1"
@click="removeMedia('photo', idx)"
>
</button>
</div> </div>
</div> </div>
</div> </div>
<div v-if="form.videos.length" class="bg-bg border border-bg-soft rounded-lg p-2"> <div class="flex justify-between items-center pt-6 border-t border-bg-soft mt-2">
<div class="text-xs text-text-muted mb-1">Vidéos jointes</div> <button type="button" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-6" @click="closeForm">Annuler</button>
<div class="space-y-2"> <button type="submit" class="btn-primary !bg-yellow !text-bg px-12 py-3 shadow-xl">
<div v-for="(url, idx) in form.videos" :key="`form-video-${idx}`" class="relative group"> {{ editId ? 'Sauvegarder' : 'Partager' }}
<video :src="url" controls muted class="w-full max-h-36 rounded border border-bg-hard bg-black/40" />
<button
type="button"
class="absolute top-1 right-1 hidden group-hover:block bg-red/80 text-white text-[10px] rounded px-1"
@click="removeMedia('video', idx)"
>
</button>
</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-1">
<button type="button" @click="closeForm" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button type="submit" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
{{ editId ? 'Enregistrer' : 'Créer' }}
</button> </button>
</div> </div>
</form> </form>
@@ -371,6 +367,12 @@ function closeForm() {
showForm.value = false showForm.value = false
} }
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
async function submitAstuce() { async function submitAstuce() {
const payload = { const payload = {
titre: form.titre.trim(), titre: form.titre.trim(),

View File

@@ -1,109 +1,120 @@
<template> <template>
<div class="p-4 max-w-4xl mx-auto"> <div class="p-4 max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-green">📷 Bibliothèque</h1> <div>
<button @click="showIdentify = true" <h1 class="text-3xl font-bold text-green tracking-tight">Bibliothèque Photo</h1>
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"> <p class="text-text-muted text-xs mt-1">Gérez vos captures et identifiez vos plantes par IA.</p>
Identifier une plante </div>
<button @click="showIdentify = true" class="btn-primary flex items-center gap-2">
<span>🔬</span> Identifier
</button> </button>
</div> </div>
<!-- Filtres --> <!-- Filtres -->
<div class="flex gap-2 mb-4 flex-wrap"> <div class="flex gap-2 mb-4 bg-bg-soft/30 p-1 rounded-full w-fit border border-bg-soft overflow-x-auto max-w-full no-scrollbar">
<button v-for="f in filters" :key="f.val" @click="activeFilter = f.val" <button v-for="f in filters" :key="f.val" @click="activeFilter = f.val"
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors', :class="['px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest transition-all whitespace-nowrap',
activeFilter === f.val ? 'bg-green text-bg' : 'bg-bg-soft text-text-muted hover:text-text']"> activeFilter === f.val ? 'bg-green text-bg shadow-lg' : 'text-text-muted hover:text-text']">
{{ f.label }} {{ f.label }}
</button> </button>
</div> </div>
<div v-if="loading" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div v-for="i in 5" :key="i" class="aspect-square card-jardin animate-pulse opacity-20"></div>
</div>
<div v-else-if="!filtered.length" class="card-jardin text-center py-16 opacity-50 border-dashed">
<div class="text-4xl mb-4">📸</div>
<p class="text-text-muted text-sm uppercase font-black tracking-widest">Aucune photo trouvée</p>
</div>
<!-- Grille --> <!-- Grille -->
<div v-if="loading" class="text-text-muted text-sm">Chargement...</div> <div v-else class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div v-else-if="!filtered.length" class="text-text-muted text-sm py-4">Aucune photo.</div>
<div v-else class="grid grid-cols-3 md:grid-cols-4 gap-2">
<div v-for="m in filtered" :key="m.id" <div v-for="m in filtered" :key="m.id"
class="aspect-square rounded-lg overflow-hidden bg-bg-hard relative group cursor-pointer" class="group relative aspect-square rounded-2xl overflow-hidden bg-bg-hard border border-bg-soft/50 cursor-pointer shadow-sm hover:shadow-2xl hover:scale-[1.02] transition-all"
@click="openLightbox(m)"> @click="openLightbox(m)">
<img :src="m.thumbnail_url || m.url" :alt="m.titre || ''" class="w-full h-full object-cover" />
<div v-if="m.identified_common" <img :src="m.thumbnail_url || m.url" :alt="m.titre || ''" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
class="absolute bottom-0 left-0 right-0 bg-black/70 text-xs text-green px-1 py-0.5 truncate">
{{ m.identified_common }} <!-- Overlay -->
</div> <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="absolute top-1 left-1 bg-black/60 text-text-muted text-xs px-1 rounded">
{{ labelFor(m.entity_type) }} <div class="absolute bottom-2 left-2 right-2 pointer-events-none">
<div v-if="m.identified_common" class="text-[10px] text-green font-black uppercase tracking-tighter truncate drop-shadow-md">
{{ m.identified_common }}
</div>
<div class="text-[8px] text-text-muted font-bold uppercase tracking-widest opacity-80">
{{ labelFor(m.entity_type) }}
</div>
</div> </div>
<button @click.stop="deleteMedia(m)" <button @click.stop="deleteMedia(m)"
class="hidden group-hover:flex absolute top-1 right-1 bg-red/80 text-white text-xs rounded px-1"></button> class="absolute top-2 right-2 bg-red/80 hover:bg-red text-white p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-all scale-75 group-hover:scale-100">
<span class="text-xs"></span>
</button>
</div> </div>
</div> </div>
<!-- Lightbox --> <!-- Lightbox stylisée -->
<div v-if="lightbox" class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4" @click.self="lightbox = null"> <div v-if="lightbox" class="fixed inset-0 bg-black/95 backdrop-blur-md z-[150] flex items-center justify-center p-4" @click.self="lightbox = null">
<div class="max-w-lg w-full bg-bg-hard rounded-xl overflow-hidden border border-bg-soft"> <div class="max-w-2xl w-full bg-bg-hard rounded-3xl overflow-hidden border border-bg-soft shadow-2xl animate-fade-in">
<img :src="lightbox.url" class="w-full" /> <div class="relative aspect-video sm:aspect-square bg-bg overflow-hidden">
<div class="p-4"> <img :src="lightbox.url" class="w-full h-full object-contain" />
<!-- Infos identification --> <button @click="lightbox = null" class="absolute top-4 right-4 bg-black/50 text-white w-10 h-10 rounded-full flex items-center justify-center hover:bg-red transition-colors"></button>
<div v-if="lightbox.identified_species" class="text-center mb-3"> </div>
<div class="text-green font-semibold text-base">{{ lightbox.identified_common }}</div>
<div class="p-6 space-y-6">
<div v-if="lightbox.identified_species" class="text-center space-y-1">
<h2 class="text-green font-black text-2xl uppercase tracking-tighter">{{ lightbox.identified_common }}</h2>
<div class="italic text-text-muted text-sm">{{ lightbox.identified_species }}</div> <div class="italic text-text-muted text-sm">{{ lightbox.identified_species }}</div>
<div class="text-xs text-text-muted mt-1"> <div class="badge badge-green !text-[9px] mt-2">
Confiance : {{ Math.round((lightbox.identified_confidence || 0) * 100) }}% via {{ lightbox.identified_source }} Confiance {{ Math.round((lightbox.identified_confidence || 0) * 100) }}% · via {{ lightbox.identified_source }}
</div> </div>
</div> </div>
<!-- Lien actuel -->
<div class="text-xs text-text-muted mb-3 text-center"> <div class="flex items-center justify-center gap-2 text-xs text-text-muted font-bold uppercase tracking-widest border-y border-bg-soft py-3">
{{ labelFor(lightbox.entity_type) }} <span>Type :</span>
<span v-if="lightbox.entity_type === 'plante' && plantName(lightbox.entity_id)"> <span class="text-text">{{ labelFor(lightbox.entity_type) }}</span>
: <span class="text-green font-medium">{{ plantName(lightbox.entity_id) }}</span> <span v-if="lightbox.entity_type === 'plante' && plantName(lightbox.entity_id)" class="text-green">
({{ plantName(lightbox.entity_id) }})
</span> </span>
</div> </div>
<!-- Actions -->
<div class="flex gap-2 flex-wrap"> <div class="flex gap-3">
<button @click="startLink(lightbox!)" <button @click="startLink(lightbox!)" class="btn-outline flex-1 py-3 border-blue/20 text-blue hover:bg-blue/10 text-[10px] font-black uppercase tracking-widest">
class="flex-1 bg-blue/20 text-blue hover:bg-blue/30 px-3 py-2 rounded-lg text-xs font-medium transition-colors"> 🔗 Associer
🔗 Associer à une plante
</button> </button>
<button <button @click="toggleAdventice(lightbox!)"
@click="toggleAdventice(lightbox!)" :class="['btn-outline flex-1 py-3 text-[10px] font-black uppercase tracking-widest',
:class="[ isAdventice(lightbox!) ? 'border-red/20 text-red hover:bg-red/10' : 'border-green/20 text-green hover:bg-green/10']">
'px-3 py-2 rounded-lg text-xs font-medium transition-colors', {{ isAdventice(lightbox!) ? '🪓 Pas Adventice' : '🌾 Adventice' }}
isAdventice(lightbox!) ? 'bg-red/20 text-red hover:bg-red/30' : 'bg-green/20 text-green hover:bg-green/30'
]"
>
{{ isAdventice(lightbox!) ? '🪓 Retirer adventice' : '🌾 Marquer adventice' }}
</button> </button>
<button @click="deleteMedia(lightbox!); lightbox = null" <button @click="deleteMedia(lightbox!); lightbox = null" class="btn-outline py-3 px-4 border-red/20 text-red hover:bg-red/10">
class="bg-red/20 text-red hover:bg-red/30 px-3 py-2 rounded-lg text-xs font-medium transition-colors"> 🗑
🗑 Supprimer
</button> </button>
</div> </div>
<button class="mt-3 w-full text-text-muted hover:text-text text-sm" @click="lightbox = null">Fermer</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal associer à une plante --> <!-- Modal associer -->
<div v-if="linkMedia" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" @click.self="linkMedia = null"> <div v-if="linkMedia" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[200] flex items-center justify-center p-4" @click.self="linkMedia = null">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-sm border border-bg-soft"> <div class="bg-bg-hard rounded-2xl p-6 w-full max-w-sm border border-bg-soft shadow-2xl">
<h3 class="text-text font-bold mb-4">Associer à une plante</h3> <h3 class="text-text font-black uppercase tracking-tighter text-lg mb-4">Lier à une plante</h3>
<select v-model="linkPlantId" <select v-model="linkPlantId"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green mb-4"> class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-green mb-6 appearance-none shadow-inner">
<option :value="null">-- Choisir une plante --</option> <option :value="null">-- Choisir dans la liste --</option>
<option v-for="p in plantsStore.plants" :key="p.id" :value="p.id"> <option v-for="p in plantsStore.plants" :key="p.id" :value="p.id">
{{ formatPlantLabel(p) }} {{ formatPlantLabel(p) }}
</option> </option>
</select> </select>
<div class="flex gap-2 justify-end"> <div class="flex gap-3 justify-end">
<button @click="linkMedia = null" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button> <button @click="linkMedia = null" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-4">Annuler</button>
<button @click="confirmLink" :disabled="!linkPlantId" <button @click="confirmLink" :disabled="!linkPlantId" class="btn-primary px-6 disabled:opacity-30">Confirmer</button>
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-40">
Associer
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal identification -->
<PhotoIdentifyModal v-if="showIdentify" @close="showIdentify = false" @identified="onIdentified" /> <PhotoIdentifyModal v-if="showIdentify" @close="showIdentify = false" @identified="onIdentified" />
</div> </div>
</template> </template>

View File

@@ -1,204 +1,229 @@
<template> <template>
<div class="p-4 max-w-6xl mx-auto"> <div class="p-4 max-w-7xl mx-auto space-y-8">
<h1 class="text-2xl font-bold text-blue mb-4">🌦 Météo</h1> <div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-blue tracking-tight">Météo & Calendrier</h1>
<p class="text-text-muted text-xs mt-1">Données locales, prévisions et cycles lunaires combinés.</p>
</div>
<div class="flex flex-wrap items-center gap-2 mb-4"> <!-- Navigateur -->
<button <div class="flex items-center gap-2 bg-bg-soft/30 p-1 rounded-xl border border-bg-soft">
class="px-3 py-1.5 rounded-md text-xs font-medium bg-bg-soft text-text hover:text-blue border border-bg-hard" <button @click="shiftWindow(-spanDays)" class="btn-outline !py-1.5 !px-3 text-xs font-bold uppercase tracking-widest border-transparent hover:bg-bg-soft transition-all">Préc.</button>
@click="shiftWindow(-spanDays)" <button @click="goToday" class="btn-primary !py-1.5 !px-4 text-xs !rounded-lg">Aujourd'hui</button>
> <button @click="shiftWindow(spanDays)" class="btn-outline !py-1.5 !px-3 text-xs font-bold uppercase tracking-widest border-transparent hover:bg-bg-soft transition-all">Suiv.</button>
Prev </div>
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium bg-blue/20 text-blue border border-blue/30"
@click="goToday"
>
Today
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium bg-bg-soft text-text hover:text-blue border border-bg-hard"
@click="shiftWindow(spanDays)"
>
Next
</button>
<span class="text-text-muted text-xs ml-1">
Fenêtre: {{ formatDate(rangeStart) }} {{ formatDate(rangeEnd) }}
</span>
</div> </div>
<div <!-- Widgets Station & Actuel -->
v-if="stationCurrent" <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
class="bg-bg-soft rounded-xl p-4 border border-bg-hard mb-4 flex flex-wrap gap-4 items-center" <div class="md:col-span-2 card-jardin border-blue/20 bg-gradient-to-br from-bg-soft to-bg-hard flex flex-wrap items-center justify-around gap-6 py-6">
> <div class="text-center">
<div> <div class="text-text-muted text-[10px] uppercase font-black tracking-widest mb-1">Température Ext.</div>
<div class="text-text-muted text-xs mb-1">Température extérieure</div> <div class="text-4xl font-bold text-text">{{ stationCurrent?.temp_ext?.toFixed(1) ?? '--' }}°<span class="text-blue text-xl">C</span></div>
<div class="text-text text-2xl font-bold">{{ stationCurrent.temp_ext?.toFixed(1) ?? '—' }}°C</div> <div v-if="stationCurrent?.date_heure" class="text-[9px] text-text-muted font-bold uppercase mt-2 opacity-50">Relevé {{ stationCurrent.date_heure.slice(11, 16) }}</div>
</div> </div>
<div class="flex gap-4 text-sm">
<span v-if="stationCurrent.humidite != null" class="text-blue">💧{{ stationCurrent.humidite }}%</span> <div class="h-12 w-px bg-bg-hard hidden sm:block"></div>
<span v-if="stationCurrent.vent_kmh != null" class="text-text">💨{{ stationCurrent.vent_kmh }} km/h {{ stationCurrent.vent_dir || '' }}</span>
<span v-if="stationCurrent.pression != null" class="text-text">🧭{{ stationCurrent.pression }} hPa</span> <div class="flex flex-col gap-3">
</div> <div class="flex items-center gap-3">
<div class="flex items-center gap-2"> <span class="w-8 text-center text-blue text-xl">💧</span>
<img <div>
v-if="currentOpenMeteo?.wmo != null" <div class="text-[9px] text-text-muted uppercase font-bold tracking-tighter leading-none">Humidité</div>
:src="weatherIcon(currentOpenMeteo.wmo)" <div class="text-sm font-bold text-text">{{ stationCurrent?.humidite ?? '--' }}%</div>
class="w-6 h-6" </div>
:alt="currentOpenMeteo.label || 'Météo'" </div>
/> <div class="flex items-center gap-3">
<div> <span class="w-8 text-center text-orange text-xl">💨</span>
<div class="text-text-muted text-xs mb-1">Condition actuelle</div> <div>
<div class="text-text text-sm">{{ currentOpenMeteo?.label || '—' }}</div> <div class="text-[9px] text-text-muted uppercase font-bold tracking-tighter leading-none">Vent</div>
<div class="text-sm font-bold text-text">{{ stationCurrent?.vent_kmh ?? '--' }} <span class="text-[10px]">km/h</span></div>
</div>
</div>
</div>
<div class="h-12 w-px bg-bg-hard hidden sm:block"></div>
<div class="flex items-center gap-4">
<img v-if="currentOpenMeteo?.wmo != null" :src="weatherIcon(currentOpenMeteo.wmo)" class="w-16 h-16 drop-shadow-xl" />
<div>
<div class="text-[9px] text-text-muted uppercase font-bold tracking-widest mb-1">État du ciel</div>
<div class="text-sm font-bold text-blue uppercase">{{ currentOpenMeteo?.label || '' }}</div>
<div class="text-[10px] text-text-muted font-mono mt-1">{{ stationCurrent?.pression ?? '--' }} hPa</div>
</div>
</div> </div>
</div> </div>
<div v-if="stationCurrent.date_heure" class="text-text-muted text-xs ml-auto">
Relevé {{ stationCurrent.date_heure.slice(11, 16) }} <div class="card-jardin border-yellow/20 flex flex-col justify-center items-center text-center py-6">
<div class="text-text-muted text-[10px] uppercase font-black tracking-widest mb-2">Lune du jour</div>
<div class="text-5xl mb-3">
{{ lunarIcon(lunarForDate(todayIso())?.illumination ?? 0) }}
</div>
<div class="badge badge-yellow !text-[10px] mb-1">{{ lunarForDate(todayIso())?.croissante_decroissante || '--' }}</div>
<div class="text-xs font-bold text-text italic">Jour {{ lunarForDate(todayIso())?.type_jour || '--' }}</div>
</div> </div>
</div> </div>
<div v-if="loadingTableau" class="text-text-muted text-sm py-4">Chargement météo...</div> <!-- Tableau Synthétique -->
<div v-else-if="!tableauRows.length" class="text-text-muted text-sm py-4">Pas de données météo.</div> <div class="grid grid-cols-1 xl:grid-cols-4 gap-8">
<div class="xl:col-span-3 space-y-4">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-aqua"></span>
Tableau de bord journalier
</h2>
<div v-else class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start"> <div class="card-jardin !p-0 overflow-hidden border-bg-hard/50 shadow-2xl">
<div class="overflow-x-auto bg-bg-soft rounded-xl border border-bg-hard p-2"> <div class="overflow-x-auto">
<table class="w-full text-sm border-collapse"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="text-text-muted text-xs"> <tr class="bg-bg-hard/50 text-[10px] font-black uppercase tracking-widest text-text-muted/60 border-b border-bg-hard">
<th class="text-left py-2 px-2">Date</th> <th class="py-4 px-4 text-left">Date</th>
<th class="text-center py-2 px-2 text-blue" colspan="3">📡 Station locale</th> <th class="py-4 px-2 text-center text-blue" colspan="3">Station</th>
<th class="text-center py-2 px-2 text-green border-l-2 border-bg-hard" colspan="4">🌐 Open-Meteo</th> <th class="py-4 px-2 text-center text-green" colspan="4">Prévisions</th>
<th class="text-center py-2 px-2 text-yellow border-l-2 border-bg-hard" colspan="3">🌙 Lunaire</th> <th class="py-4 px-4 text-right text-yellow">Lune</th>
</tr> </tr>
<tr class="text-text-muted text-xs border-b border-bg-hard"> <tr class="text-[9px] font-bold uppercase tracking-tighter text-text-muted border-b border-bg-hard">
<th class="text-left py-1 px-2"></th> <th class="py-2 px-4"></th>
<th class="text-right py-1 px-1">T°min</th> <th class="px-1 text-right opacity-60">Min</th>
<th class="text-right py-1 px-1">T°max</th> <th class="px-1 text-right opacity-60">Max</th>
<th class="text-right py-1 px-1">💧mm</th> <th class="px-1 text-right opacity-60">Pluie</th>
<th class="px-1 text-right opacity-60 border-l border-bg-hard/30">Min</th>
<th class="px-1 text-right opacity-60">Max</th>
<th class="px-1 text-right opacity-60">Pluie</th>
<th class="px-2 text-left opacity-60">Ciel</th>
<th class="px-4 text-right opacity-60">Calendrier</th>
</tr>
</thead>
<tbody>
<tr v-for="row in tableauRows" :key="row.date"
@click="selectMeteoDate(row.date)"
:class="['group cursor-pointer border-b border-bg-hard/30 transition-all hover:bg-bg-soft/20',
row.type === 'aujourd_hui' ? 'bg-blue/5 !border-blue/30' : '',
selectedMeteoDate === row.date ? 'bg-blue/10 !border-blue' : '']">
<th class="text-right py-1 px-1 border-l-2 border-bg-hard">T°min</th> <td class="py-3 px-4">
<th class="text-right py-1 px-1">T°max</th> <div :class="['text-sm font-black font-mono', row.type === 'aujourd_hui' ? 'text-blue' : 'text-text']">
<th class="text-right py-1 px-1">💧mm</th> {{ formatDate(row.date) }}
<th class="text-left py-1 px-2">État</th> </div>
<div v-if="row.type === 'aujourd_hui'" class="text-[8px] uppercase tracking-tighter text-blue font-black mt-0.5">Aujourd'hui</div>
</td>
<th class="text-left py-1 px-2 border-l-2 border-bg-hard">Type lune</th> <td class="text-right px-1 font-mono text-xs text-blue">{{ stationTMin(row) }}</td>
<th class="text-left py-1 px-2">Mont./Desc.</th> <td class="text-right px-1 font-mono text-xs text-orange font-bold">{{ stationTMax(row) }}</td>
<th class="text-left py-1 px-2">Type jour</th> <td class="text-right px-1 font-mono text-xs text-aqua">{{ stationRain(row) }}</td>
</tr>
</thead>
<tbody>
<tr
v-for="row in tableauRows"
:key="row.date"
@click="selectMeteoDate(row.date)"
:class="[
'border-b border-bg-hard transition-colors cursor-pointer',
row.type === 'passe' ? 'opacity-80' : '',
row.date === selectedMeteoDate ? 'bg-blue/10 border-blue' : '',
]"
>
<td class="py-2 px-2 text-text-muted text-xs whitespace-nowrap">
<span :class="row.type === 'aujourd_hui' ? 'text-green font-bold' : ''">
{{ formatDate(row.date) }}
</span>
</td>
<td class="text-right px-1 text-blue text-xs">{{ stationTMin(row) }}</td> <td class="text-right px-1 font-mono text-xs text-blue border-l border-bg-hard/30">{{ omTMin(row) }}</td>
<td class="text-right px-1 text-orange text-xs">{{ stationTMax(row) }}</td> <td class="text-right px-1 font-mono text-xs text-orange font-bold">{{ omTMax(row) }}</td>
<td class="text-right px-1 text-blue text-xs">{{ stationRain(row) }}</td> <td class="text-right px-1 font-mono text-xs text-aqua">{{ omRain(row) }}</td>
<td class="text-right px-1 text-blue text-xs border-l-2 border-bg-hard">{{ omTMin(row) }}</td> <td class="px-2">
<td class="text-right px-1 text-orange text-xs">{{ omTMax(row) }}</td> <div class="flex items-center gap-2">
<td class="text-right px-1 text-blue text-xs">{{ omRain(row) }}</td> <img v-if="row.open_meteo?.wmo != null" :src="weatherIcon(row.open_meteo.wmo)" class="w-6 h-6 opacity-90 group-hover:scale-110 transition-transform" />
<td class="px-2"> <span class="text-[11px] font-medium text-text-muted truncate max-w-[85px]">{{ row.open_meteo?.label || '—' }}</span>
<div class="flex items-center gap-1"> </div>
<img </td>
v-if="row.open_meteo?.wmo != null"
:src="weatherIcon(row.open_meteo.wmo)"
class="w-5 h-5"
:alt="row.open_meteo.label"
/>
<span class="text-text-muted text-xs">{{ row.open_meteo?.label || '—' }}</span>
</div>
</td>
<td class="px-2 text-xs border-l-2 border-bg-hard"> <td class="py-3 px-4 text-right">
{{ lunarForDate(row.date)?.croissante_decroissante || '—' }} <div class="flex items-center justify-end gap-3">
</td> <!-- Élément -->
<td class="px-2 text-xs"> <span :title="lunarForDate(row.date)?.type_jour || ''" class="text-xl filter drop-shadow-sm">
{{ lunarForDate(row.date)?.montante_descendante || '' }} {{ typeIcon(lunarForDate(row.date)?.type_jour || '') }}
</td> </span>
<td class="px-2 text-xs" :class="typeColor(lunarForDate(row.date)?.type_jour || '')"> <!-- Mouvement -->
{{ lunarForDate(row.date)?.type_jour || '—' }} <span :title="lunarForDate(row.date)?.montante_descendante || ''" class="text-sm font-bold">
</td> {{ movementIcon(lunarForDate(row.date)?.montante_descendante || '') }}
</tr> </span>
</tbody> <!-- Signe -->
</table> <span :title="lunarForDate(row.date)?.signe || ''" class="text-base opacity-80">
{{ zodiacIcon(lunarForDate(row.date)?.signe || '') }}
</span>
<!-- Phase -->
<span class="text-xl leading-none" :title="lunarForDate(row.date)?.croissante_decroissante || ''">
{{ lunarIcon(lunarForDate(row.date)?.illumination ?? 0) }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Légende enrichie -->
<div class="flex justify-end">
<div class="bg-bg-hard/50 rounded-2xl p-4 border border-bg-soft inline-grid grid-cols-3 gap-x-8 gap-y-3 shadow-inner text-xs">
<div class="col-span-3 text-[9px] font-black uppercase tracking-widest text-text-muted mb-1 border-b border-bg-soft pb-1">Légende du Calendrier</div>
<div class="space-y-1">
<div class="text-[8px] font-bold text-text-muted uppercase tracking-tighter opacity-60">Éléments</div>
<div class="grid grid-cols-2 gap-1 text-[10px] text-text-muted">
<span>🥕 Racine</span> <span>🌿 Feuille</span>
<span>🌸 Fleur</span> <span>🍎 Fruit</span>
</div>
</div>
<div class="space-y-1">
<div class="text-[8px] font-bold text-text-muted uppercase tracking-tighter opacity-60">Mouvement</div>
<div class="grid grid-cols-1 gap-1 text-[10px] text-text-muted">
<span> <span class="font-bold text-yellow">Montante</span> (semis)</span>
<span> <span class="font-bold text-aqua">Descendante</span> (plant.)</span>
</div>
</div>
<div class="space-y-1">
<div class="text-[8px] font-bold text-text-muted uppercase tracking-tighter opacity-60">Signes</div>
<div class="text-[10px] text-text-muted font-mono leading-tight">
<br/>
</div>
</div>
</div>
</div>
</div> </div>
<aside class="bg-bg-soft rounded-xl border border-bg-hard p-4"> <!-- Détail Latéral -->
<div v-if="!selectedMeteoRow" class="text-text-muted text-sm">Sélectionne un jour dans le tableau pour voir le détail.</div> <aside class="space-y-6">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow"></span>
Focus Journée
</h2>
<div v-else class="space-y-3"> <div v-if="selectedMeteoRow" class="card-jardin space-y-6 animate-fade-in">
<div> <div>
<h3 class="text-text font-semibold">{{ formatDateLong(selectedMeteoRow.date) }}</h3> <h3 class="text-text font-black text-lg leading-tight uppercase tracking-tighter">{{ formatDateLong(selectedMeteoRow.date) }}</h3>
<p class="text-text-muted text-xs"> <span class="badge badge-aqua !text-[9px] mt-1">{{ selectedSaint || 'Sainte Nature' }}</span>
{{ selectedMeteoRow.type === 'passe' ? 'Historique' : selectedMeteoRow.type === 'aujourd_hui' ? 'Aujourd\'hui' : 'Prévision' }}
</p>
</div> </div>
<div class="pt-2 border-t border-bg-hard"> <div class="space-y-4">
<div class="text-blue text-xs font-semibold mb-1">📡 Station locale</div> <div v-if="selectedLunarDay" class="bg-bg-hard/50 rounded-xl p-3 border border-yellow/10">
<div class="text-xs text-text-muted space-y-1"> <div class="text-[9px] font-black uppercase tracking-widest text-yellow mb-2">Cycle Lunaire</div>
<div v-if="selectedMeteoRow.station && 'temp_ext' in selectedMeteoRow.station && selectedMeteoRow.station.temp_ext != null"> <div class="grid grid-cols-2 gap-2 text-[11px]">
T° actuelle: <span class="text-text">{{ selectedMeteoRow.station.temp_ext.toFixed(1) }}°</span> <div class="text-text-muted">Type: <span class="text-text font-bold">{{ selectedLunarDay.croissante_decroissante }}</span></div>
</div> <div class="text-text-muted">Mouv.: <span class="text-text font-bold">{{ selectedLunarDay.montante_descendante }}</span></div>
<div>T° min: <span class="text-text">{{ stationTMin(selectedMeteoRow) }}</span></div> <div class="text-text-muted">Signe: <span class="text-text font-bold">{{ selectedLunarDay.signe }}</span></div>
<div>T° max/actuelle: <span class="text-text">{{ stationTMax(selectedMeteoRow) }}</span></div> <div class="text-text-muted">Lumière: <span class="text-text font-bold">{{ selectedLunarDay.illumination }}%</span></div>
<div>Pluie: <span class="text-text">{{ stationRain(selectedMeteoRow) }}</span></div> <div class="text-text-muted col-span-2">Élément: <span class="text-text font-bold">{{ selectedLunarDay.type_jour || '--' }}</span></div>
<div v-if="selectedMeteoRow.station && 'vent_kmh' in selectedMeteoRow.station && selectedMeteoRow.station.vent_kmh != null">
Vent max: <span class="text-text">{{ selectedMeteoRow.station.vent_kmh }} km/h</span>
</div>
<div v-if="selectedMeteoRow.station && 'humidite' in selectedMeteoRow.station && selectedMeteoRow.station.humidite != null">
Humidité: <span class="text-text">{{ selectedMeteoRow.station.humidite }}%</span>
</div> </div>
</div> </div>
</div>
<div class="pt-2 border-t border-bg-hard">
<div class="text-green text-xs font-semibold mb-1">🌐 Open-Meteo</div>
<div class="text-xs text-text-muted space-y-1">
<div>T° min: <span class="text-text">{{ omTMin(selectedMeteoRow) }}</span></div>
<div>T° max: <span class="text-text">{{ omTMax(selectedMeteoRow) }}</span></div>
<div>Pluie: <span class="text-text">{{ omRain(selectedMeteoRow) }}</span></div>
<div>État: <span class="text-text">{{ selectedMeteoRow.open_meteo?.label || '—' }}</span></div>
<div v-if="selectedMeteoRow.open_meteo?.vent_kmh != null">Vent: <span class="text-text">{{ selectedMeteoRow.open_meteo.vent_kmh }} km/h</span></div>
<div v-if="selectedMeteoRow.open_meteo?.sol_0cm != null">Sol 0cm: <span class="text-text">{{ selectedMeteoRow.open_meteo.sol_0cm }}°C</span></div>
<div v-if="selectedMeteoRow.open_meteo?.etp_mm != null">ETP: <span class="text-text">{{ selectedMeteoRow.open_meteo.etp_mm }} mm</span></div>
</div>
</div>
<div class="pt-2 border-t border-bg-hard">
<div class="text-yellow text-xs font-semibold mb-1">🌙 Lunaire</div>
<div v-if="selectedLunarDay" class="text-xs text-text-muted space-y-1">
<div>Type lune: <span class="text-text">{{ selectedLunarDay.croissante_decroissante }}</span></div>
<div>Montante/Descendante: <span class="text-text">{{ selectedLunarDay.montante_descendante }}</span></div>
<div>Type de jour: <span :class="['font-semibold', typeColor(selectedLunarDay.type_jour)]">{{ selectedLunarDay.type_jour }}</span></div>
<div>Signe: <span class="text-text">{{ selectedLunarDay.signe }}</span></div>
<div>Illumination: <span class="text-text">{{ selectedLunarDay.illumination }}%</span></div>
<div>Saint: <span class="text-text">{{ selectedSaint || '—' }}</span></div>
</div>
<div v-else class="text-xs text-text-muted">Donnée lunaire indisponible pour cette date.</div>
</div>
<div class="pt-2 border-t border-bg-hard">
<div class="text-orange text-xs font-semibold mb-1">📜 Dictons</div>
<div v-if="selectedDictons.length" class="space-y-2"> <div v-if="selectedDictons.length" class="space-y-2">
<p v-for="d in selectedDictons" :key="`detail-dicton-${d.id}`" class="text-xs text-text-muted italic"> <div class="text-[9px] font-black uppercase tracking-widest text-orange opacity-60">Sagesse du jour</div>
<p v-for="d in selectedDictons" :key="d.id" class="text-xs text-text-muted italic bg-bg-hard/30 p-2 rounded-lg leading-relaxed border-l-2 border-orange/30">
"{{ d.texte }}" "{{ d.texte }}"
</p> </p>
</div> </div>
<div v-else class="text-xs text-text-muted">Aucun dicton trouvé pour ce jour.</div>
</div> </div>
<div class="pt-4 border-t border-bg-hard flex justify-center">
<div class="text-center">
<div class="text-[9px] font-black uppercase tracking-widest text-green mb-2 opacity-60">Prévision Sol</div>
<div class="text-2xl font-mono text-text">{{ selectedMeteoRow.open_meteo?.sol_0cm?.toFixed(1) ?? '--' }}°C</div>
<div class="text-[10px] text-text-muted font-bold mt-1">à 0cm</div>
</div>
</div>
</div>
<div v-else class="card-jardin text-center py-12 opacity-30 border-dashed">
<p class="text-[10px] font-bold uppercase">Sélectionner une date</p>
</div> </div>
</aside> </aside>
</div> </div>
@@ -210,7 +235,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { lunarApi, type Dicton, type LunarDay } from '@/api/lunar' import { lunarApi, type Dicton, type LunarDay } from '@/api/lunar'
import { meteoApi, type StationCurrent, type TableauRow } from '@/api/meteo' import { meteoApi, type StationCurrent, type TableauRow } from '@/api/meteo'
const spanDays = 15 const spanDays = 10
const tableauRows = ref<TableauRow[]>([]) const tableauRows = ref<TableauRow[]>([])
const loadingTableau = ref(false) const loadingTableau = ref(false)
@@ -226,28 +251,19 @@ const rangeStart = computed(() => shiftIso(centerDate.value, -spanDays))
const rangeEnd = computed(() => shiftIso(centerDate.value, spanDays)) const rangeEnd = computed(() => shiftIso(centerDate.value, spanDays))
const saintsFallback: Record<string, string> = { const saintsFallback: Record<string, string> = {
'04-23': 'Saint Georges', '04-23': 'Saint Georges', '04-25': 'Saint Marc', '05-11': 'Saint Mamert',
'04-25': 'Saint Marc', '05-12': 'Saint Pancrace', '05-13': 'Saint Servais', '05-14': 'Saint Boniface',
'05-11': 'Saint Mamert', '05-19': 'Saint Yves', '05-25': 'Saint Urbain',
'05-12': 'Saint Pancrace',
'05-13': 'Saint Servais',
'05-14': 'Saint Boniface',
'05-19': 'Saint Yves',
'05-25': 'Saint Urbain',
} }
const selectedMeteoRow = computed(() => tableauRows.value.find((r) => r.date === selectedMeteoDate.value) || null) const selectedMeteoRow = computed(() => tableauRows.value.find((r) => r.date === selectedMeteoDate.value) || null)
const selectedLunarDay = computed(() => lunarByDate.value[selectedMeteoDate.value] || null) const selectedLunarDay = computed(() => lunarByDate.value[selectedMeteoDate.value] || null)
const currentOpenMeteo = computed(() => { const currentOpenMeteo = computed(() => tableauRows.value.find((r) => r.type === 'aujourd_hui')?.open_meteo || null)
const today = tableauRows.value.find((r) => r.type === 'aujourd_hui')
return today?.open_meteo || null
})
const selectedSaint = computed(() => { const selectedSaint = computed(() => {
if (!selectedMeteoDate.value) return '' if (!selectedMeteoDate.value) return ''
if (selectedLunarDay.value?.saint_du_jour) return selectedLunarDay.value.saint_du_jour if (selectedLunarDay.value?.saint_du_jour) return selectedLunarDay.value.saint_du_jour
const mmdd = selectedMeteoDate.value.slice(5) return saintsFallback[selectedMeteoDate.value.slice(5)] || ''
return saintsFallback[mmdd] || ''
}) })
const selectedDictons = computed(() => { const selectedDictons = computed(() => {
@@ -255,10 +271,8 @@ const selectedDictons = computed(() => {
const month = monthFromIso(selectedMeteoDate.value) const month = monthFromIso(selectedMeteoDate.value)
const day = dayFromIso(selectedMeteoDate.value) const day = dayFromIso(selectedMeteoDate.value)
const rows = dictonsByMonth.value[month] || [] const rows = dictonsByMonth.value[month] || []
const exact = rows.filter((d) => d.jour === day) const exact = rows.filter((d) => d.jour === day)
if (exact.length) return exact return exact.length ? exact : rows.filter((d) => d.jour == null).slice(0, 3)
return rows.filter((d) => d.jour == null).slice(0, 3)
}) })
function todayIso(): string { function todayIso(): string {
@@ -272,21 +286,10 @@ function shiftIso(isoDate: string, days: number): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
function shiftWindow(days: number) { function shiftWindow(days: number) { centerDate.value = shiftIso(centerDate.value, days) }
centerDate.value = shiftIso(centerDate.value, days) function goToday() { centerDate.value = todayIso() }
} function monthFromIso(isoDate: string): number { return Number(isoDate.slice(5, 7)) }
function dayFromIso(isoDate: string): number { return Number(isoDate.slice(8, 10)) }
function goToday() {
centerDate.value = todayIso()
}
function monthFromIso(isoDate: string): number {
return Number(isoDate.slice(5, 7))
}
function dayFromIso(isoDate: string): number {
return Number(isoDate.slice(8, 10))
}
function selectMeteoDate(isoDate: string) { function selectMeteoDate(isoDate: string) {
selectedMeteoDate.value = isoDate selectedMeteoDate.value = isoDate
@@ -306,124 +309,77 @@ async function ensureDictonsMonth(month: number) {
async function loadLunarForTableau() { async function loadLunarForTableau() {
const months = Array.from(new Set(tableauRows.value.map((r) => r.date.slice(0, 7)))) const months = Array.from(new Set(tableauRows.value.map((r) => r.date.slice(0, 7))))
const map: Record<string, LunarDay> = {} const map: Record<string, LunarDay> = {}
for (const month of months) { for (const month of months) {
try { try {
const days = await lunarApi.getMonth(month) const days = await lunarApi.getMonth(month)
for (const d of days) map[d.date] = d for (const d of days) map[d.date] = d
} catch { } catch {}
// Mois indisponible: on laisse les cellules lunaires vides
}
} }
lunarByDate.value = map lunarByDate.value = map
} }
async function loadTableau() { async function loadTableau() {
loadingTableau.value = true loadingTableau.value = true
try { try {
const res = await meteoApi.getTableau({ const res = await meteoApi.getTableau({ center_date: centerDate.value, span: spanDays })
center_date: centerDate.value,
span: spanDays,
})
tableauRows.value = res.rows || [] tableauRows.value = res.rows || []
await loadLunarForTableau() await loadLunarForTableau()
const selectedStillVisible = tableauRows.value.some((r) => r.date === selectedMeteoDate.value)
if (selectedStillVisible) return
const todayRow = tableauRows.value.find((r) => r.type === 'aujourd_hui') const todayRow = tableauRows.value.find((r) => r.type === 'aujourd_hui')
if (todayRow) { if (todayRow && !selectedMeteoDate.value) selectMeteoDate(todayRow.date)
selectMeteoDate(todayRow.date)
} else if (tableauRows.value.length) {
selectMeteoDate(tableauRows.value[0].date)
}
} catch {
tableauRows.value = []
lunarByDate.value = {}
} finally { } finally {
loadingTableau.value = false loadingTableau.value = false
} }
} }
async function loadStationCurrent() { async function loadStationCurrent() {
try { try { stationCurrent.value = await meteoApi.getStationCurrent() } catch { stationCurrent.value = null }
stationCurrent.value = await meteoApi.getStationCurrent()
} catch {
stationCurrent.value = null
}
} }
function lunarForDate(isoDate: string): LunarDay | null { function lunarForDate(isoDate: string): LunarDay | null { return lunarByDate.value[isoDate] || null }
return lunarByDate.value[isoDate] || null
}
function stationTMin(row: TableauRow): string { function stationTMin(row: TableauRow): string {
if (row.station && 't_min' in row.station && row.station.t_min != null) return `${row.station.t_min.toFixed(1)}°` return (row.station && 't_min' in row.station && row.station.t_min != null) ? `${row.station.t_min.toFixed(1)}°` : '—'
return '—'
} }
function stationTMax(row: TableauRow): string { function stationTMax(row: TableauRow): string {
if (row.station && 't_max' in row.station && row.station.t_max != null) return `${row.station.t_max.toFixed(1)}°` if (row.station && 't_max' in row.station && row.station.t_max != null) return `${row.station.t_max.toFixed(1)}°`
if (row.type === 'aujourd_hui' && row.station && 'temp_ext' in row.station && row.station.temp_ext != null) { if (row.type === 'aujourd_hui' && row.station && 'temp_ext' in row.station && row.station.temp_ext != null) return `${row.station.temp_ext.toFixed(1)}°`
return `${row.station.temp_ext.toFixed(1)}° act.`
}
return '—' return '—'
} }
function stationRain(row: TableauRow): string { function stationRain(row: TableauRow): string { return row.station?.pluie_mm != null ? String(row.station.pluie_mm) : '—' }
if (row.station && row.station.pluie_mm != null) return String(row.station.pluie_mm) function omTMin(row: TableauRow): string { return row.open_meteo?.t_min != null ? `${row.open_meteo.t_min.toFixed(1)}°` : '—' }
return '—' function omTMax(row: TableauRow): string { return row.open_meteo?.t_max != null ? `${row.open_meteo.t_max.toFixed(1)}°` : '—' }
} function omRain(row: TableauRow): string { return row.open_meteo?.pluie_mm != null ? String(row.open_meteo.pluie_mm) : '—' }
function omTMin(row: TableauRow): string {
return row.open_meteo?.t_min != null ? `${row.open_meteo.t_min.toFixed(1)}°` : '—'
}
function omTMax(row: TableauRow): string {
return row.open_meteo?.t_max != null ? `${row.open_meteo.t_max.toFixed(1)}°` : '—'
}
function omRain(row: TableauRow): string {
return row.open_meteo?.pluie_mm != null ? String(row.open_meteo.pluie_mm) : '—'
}
function weatherIcon(code: number): string { function weatherIcon(code: number): string {
const available = [0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99] const available = [0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99]
const closest = available.reduce((prev, curr) => const closest = available.reduce((p, c) => Math.abs(c - code) < Math.abs(p - code) ? c : p)
Math.abs(curr - code) < Math.abs(prev - code) ? curr : prev,
)
return `/icons/weather/${closest}.svg` return `/icons/weather/${closest}.svg`
} }
function typeColor(type: string): string { function lunarIcon(illu: number): string {
return ({ Racine: 'text-yellow', Feuille: 'text-green', Fleur: 'text-orange', Fruit: 'text-red' } as Record<string, string>)[type] || 'text-text-muted' if (illu < 5) return '🌑'; if (illu < 25) return '🌒'; if (illu < 45) return '🌓'; if (illu < 65) return '🌔';
if (illu < 85) return '🌕'; if (illu < 95) return '🌖'; if (illu < 98) return '🌗'; return '🌘'
} }
function formatDate(dateStr: string): string { function typeIcon(t: string): string {
return new Date(`${dateStr}T12:00:00`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) return ({ Racine: '🥕', Feuille: '🌿', Fleur: '🌸', Fruit: '🍎' } as any)[t] || '—'
} }
function formatDateLong(dateStr: string): string { function movementIcon(md: string): string { return md === 'Montante' ? '↗️' : md === 'Descendante' ? '↘️' : '' }
return new Date(`${dateStr}T12:00:00`).toLocaleDateString('fr-FR', {
weekday: 'long', function zodiacIcon(s: string): string {
day: 'numeric', return ({
month: 'long', 'Bélier': '♈', 'Taureau': '♉', 'Gémeaux': '♊', 'Cancer': '',
year: 'numeric', 'Lion': '♌', 'Vierge': '♍', 'Balance': '♎', 'Scorpion': '♏',
}) 'Sagittaire': '♐', 'Capricorne': '♑', 'Verseau': '♒', 'Poissons': '♓'
} as any)[s] || ''
} }
watch(centerDate, () => { function formatDate(ds: string): string { return new Date(`${ds}T12:00:00`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) }
void loadTableau() function formatDateLong(ds: string): string { return new Date(`${ds}T12:00:00`).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }) }
})
watch(selectedMeteoDate, (iso) => { watch(centerDate, loadTableau)
if (!iso) return onMounted(() => { void loadTableau(); void loadStationCurrent() })
void ensureDictonsMonth(monthFromIso(iso))
})
onMounted(() => {
void loadTableau()
void loadStationCurrent()
})
</script> </script>

View File

@@ -1,82 +1,147 @@
<template> <template>
<div class="p-4 max-w-6xl mx-auto"> <div class="p-4 max-w-6xl mx-auto space-y-8">
<h1 class="text-2xl font-bold text-green mb-6">Tableau de bord</h1> <div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-green tracking-tight">Tableau de bord</h1>
<section class="mb-6"> <div class="text-text-muted text-xs font-medium bg-bg-hard px-3 py-1 rounded-full border border-bg-soft">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Tâches à faire</h2> 🌿 {{ new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }) }}
<div v-if="!pendingTasks.length" class="text-text-muted text-sm py-2">Aucune tâche en attente.</div>
<div
v-for="t in pendingTasks"
: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 px-2"
@click="tasksStore.updateStatut(t.id!, 'fait')"
> Fait</button>
</div> </div>
</section> </div>
<section class="mb-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Météo</h2> <!-- Section Tâches -->
<section class="lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow"></span>
Tâches prioritaires
</h2>
<RouterLink to="/taches" class="text-xs text-yellow hover:underline font-bold uppercase tracking-wide">Voir tout</RouterLink>
</div>
<div v-if="stationCurrent || meteo7j.length" class="bg-bg-soft rounded-xl p-3 border border-bg-hard mb-3"> <div v-if="!pendingTasks.length" class="card-jardin text-center py-10 opacity-50">
<div class="text-text-muted text-xs mb-1">Condition actuelle</div> <p class="text-text-muted text-sm">Aucune tâche en attente. Profitez du jardin ! </p>
<div class="flex items-center gap-3"> </div>
<img <div class="space-y-3">
v-if="meteoCurrent?.wmo != null" <div
:src="weatherIcon(meteoCurrent.wmo)" v-for="t in pendingTasks"
class="w-8 h-8" :key="t.id"
:alt="meteoCurrent.label || 'Météo'" class="card-jardin flex items-center gap-4 group"
/> >
<div class="text-sm text-text"> <div :class="[
<div>{{ meteoCurrent?.label || '—' }}</div> 'w-2 h-10 rounded-full shrink-0',
<div class="text-text-muted text-xs"> t.priorite === 'haute' ? 'bg-red' : t.priorite === 'normale' ? 'bg-yellow' : 'bg-bg-hard'
{{ stationCurrent?.temp_ext != null ? `${stationCurrent.temp_ext.toFixed(1)}°C` : 'Temp. indisponible' }} ]"></div>
<span v-if="stationCurrent?.date_heure"> · relevé {{ stationCurrent.date_heure.slice(11, 16) }}</span> <div class="flex-1 min-w-0">
<div class="text-text font-semibold text-sm truncate">{{ t.titre }}</div>
<div class="text-text-muted text-[10px] uppercase font-bold tracking-tight">Priorité {{ t.priorite }}</div>
</div> </div>
<button
class="btn-outline py-1 px-3 border-green/30 text-green hover:bg-green/10"
@click="tasksStore.updateStatut(t.id!, 'fait')"
>Terminer</button>
</div> </div>
</div> </div>
</div> </section>
<div v-if="meteo7j.length" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-2"> <!-- Section Météo Actuelle -->
<div v-for="day in meteo7j" :key="day.date" <section>
class="bg-bg-soft rounded-xl p-3 border border-bg-hard flex flex-col items-center gap-1 min-w-0"> <h2 class="text-text-muted text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2">
<div class="text-text-muted text-xs">{{ formatDate(day.date || '') }}</div> <span class="w-2 h-2 rounded-full bg-blue"></span>
Météo Locale
</h2>
<div class="card-jardin border-blue/20 bg-gradient-to-br from-bg-soft to-bg-hard relative overflow-hidden">
<div v-if="meteoCurrent" class="relative z-10 flex flex-col items-center py-4">
<img
v-if="meteoCurrent?.wmo != null"
:src="weatherIcon(meteoCurrent.wmo)"
class="drop-shadow-2xl mb-2"
:style="{ width: 'calc(var(--ui-weather-icon-size, 48px) * 2)', height: 'calc(var(--ui-weather-icon-size, 48px) * 2)' }"
:alt="meteoCurrent.label || 'Météo'"
/>
<div class="text-3xl font-bold text-text mb-1">
{{ stationCurrent?.temp_ext != null ? `${stationCurrent.temp_ext.toFixed(1)}°C` : '—°' }}
</div>
<div class="text-sm font-bold text-blue uppercase tracking-widest mb-4">{{ meteoCurrent?.label || '—' }}</div>
<div class="grid grid-cols-2 gap-4 w-full pt-4 border-t border-bg-hard/50">
<div class="text-center">
<div class="text-[10px] text-text-muted uppercase font-bold">Humidité</div>
<div class="text-sm text-blue">{{ stationCurrent?.humidite ?? '--' }}%</div>
</div>
<div class="text-center">
<div class="text-[10px] text-text-muted uppercase font-bold">Vent</div>
<div class="text-sm text-text">{{ stationCurrent?.vent_kmh ?? '--' }} <span class="text-[10px]">km/h</span></div>
</div>
</div>
</div>
<div v-else class="text-center py-10 opacity-50 text-sm">Connexion météo...</div>
</div>
</section>
</div>
<!-- Section Prévisions 7j -->
<section>
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-aqua"></span>
Prévisions de la semaine
</h2>
<div v-if="meteo7j.length" class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<div v-for="(day, index) in meteo7j" :key="day.date"
:class="['card-jardin flex flex-col items-center gap-2 text-center transition-all group hover:-translate-y-1',
index === 0 ? 'border-yellow/30 bg-yellow/5' : '']">
<div class="text-[10px] font-bold uppercase tracking-tighter" :class="index === 0 ? 'text-yellow' : 'text-text-muted'">
{{ index === 0 ? 'Aujourd\'hui' : formatDate(day.date || '') }}
</div>
<img <img
v-if="day.wmo != null" v-if="day.wmo != null"
:src="weatherIcon(day.wmo)" :src="weatherIcon(day.wmo)"
class="w-8 h-8" class="group-hover:scale-110 transition-transform"
:style="{ width: 'var(--ui-weather-icon-size, 48px)', height: 'var(--ui-weather-icon-size, 48px)' }"
:alt="day.label || 'Météo'" :alt="day.label || 'Météo'"
/> />
<div v-else class="text-2xl"></div> <div class="text-[10px] text-text-muted font-medium line-clamp-1 h-3">{{ day.label || '—' }}</div>
<div class="text-[11px] text-center text-text-muted leading-tight min-h-[30px]">{{ day.label || '—' }}</div> <div class="flex gap-2 text-xs font-bold pt-1">
<div class="flex gap-1 text-xs"> <span class="text-orange">{{ day.t_max?.toFixed(1) }}°</span>
<span class="text-orange">{{ day.t_max != null ? day.t_max.toFixed(0) : '—' }}°</span> <span class="text-blue">{{ day.t_min?.toFixed(1) }}°</span>
<span class="text-blue">{{ day.t_min != null ? day.t_min.toFixed(0) : '—' }}°</span>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="text-text-muted text-sm py-2">Prévisions indisponibles.</div>
</section> </section>
<!-- Section Jardins -->
<section> <section>
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Jardins</h2> <div class="flex items-center justify-between mb-4">
<div v-if="gardensStore.loading" class="text-text-muted text-sm">Chargement...</div> <h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<div <span class="w-2 h-2 rounded-full bg-orange"></span>
v-for="g in gardensStore.gardens" Mes Jardins
:key="g.id" </h2>
class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard cursor-pointer hover:border-green transition-colors" <button class="btn-primary py-1 px-4 text-xs" @click="router.push('/jardins')">Gérer</button>
@click="router.push(`/jardins/${g.id}`)" </div>
>
<span class="text-text font-medium">{{ g.nom }}</span> <div v-if="gardensStore.loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<span class="ml-2 text-xs text-text-muted px-2 py-0.5 bg-bg rounded">{{ g.type }}</span> <div v-for="i in 3" :key="i" class="card-jardin h-24 animate-pulse opacity-20"></div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="g in gardensStore.gardens"
:key="g.id"
class="card-jardin flex flex-col justify-between group cursor-pointer hover:border-green/50"
@click="router.push(`/jardins/${g.id}`)"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl" :style="{ fontSize: 'var(--ui-dashboard-icon-size, 24px)' }">🪴</span>
<h3 class="text-text font-bold text-lg group-hover:text-green transition-colors">{{ g.nom }}</h3>
</div>
<span class="badge badge-yellow">{{ g.type?.replace('_', ' ') }}</span>
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-[10px] text-text-muted uppercase font-bold tracking-widest">{{ g.exposition }} · {{ g.surface_m2 }}</span>
<div class="text-green text-xl opacity-0 group-hover:opacity-100 transition-all"></div>
</div>
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -84,14 +149,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useGardensStore } from '@/stores/gardens' import { useGardensStore } from '@/stores/gardens'
import { useTasksStore } from '@/stores/tasks' import { useTasksStore } from '@/stores/tasks'
import { meteoApi, type OpenMeteoDay, type StationCurrent } from '@/api/meteo' import { meteoApi, type OpenMeteoDay, type StationCurrent } from '@/api/meteo'
import { useToast } from '@/composables/useToast'
const router = useRouter() const router = useRouter()
const gardensStore = useGardensStore() const gardensStore = useGardensStore()
const tasksStore = useTasksStore() const tasksStore = useTasksStore()
const toast = useToast()
const pendingTasks = computed(() => tasksStore.tasks.filter(t => t.statut === 'a_faire').slice(0, 5)) const pendingTasks = computed(() => tasksStore.tasks.filter(t => t.statut === 'a_faire').slice(0, 5))
const meteo7j = ref<OpenMeteoDay[]>([]) const meteo7j = ref<OpenMeteoDay[]>([])
@@ -100,7 +167,8 @@ const meteoCurrent = computed(() => meteo7j.value[0] || null)
function formatDate(s: string) { function formatDate(s: string) {
if (!s) return '—' if (!s) return '—'
return new Date(s + 'T12:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) const date = new Date(s + 'T12:00:00')
return date.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric' })
} }
function weatherIcon(code: number): string { function weatherIcon(code: number): string {
@@ -112,8 +180,11 @@ function weatherIcon(code: number): string {
} }
onMounted(async () => { onMounted(async () => {
gardensStore.fetchAll() // Chargement des données principales (silencieux si erreur)
tasksStore.fetchAll() try { await gardensStore.fetchAll() } catch { toast.warning('Jardins non disponibles') }
try { await tasksStore.fetchAll() } catch { toast.warning('Tâches non disponibles') }
// Météo : fallback silencieux, pas de toast intrusif
try { stationCurrent.value = await meteoApi.getStationCurrent() } catch { stationCurrent.value = null } try { stationCurrent.value = await meteoApi.getStationCurrent() } catch { stationCurrent.value = null }
try { const r = await meteoApi.getPrevisions(7); meteo7j.value = r.days.slice(0, 7) } catch { meteo7j.value = [] } try { const r = await meteoApi.getPrevisions(7); meteo7j.value = r.days.slice(0, 7) } catch { meteo7j.value = [] }
}) })

View File

@@ -3,7 +3,9 @@
<button class="text-text-muted text-sm mb-4 hover:text-text" @click="router.back()"> Retour</button> <button class="text-text-muted text-sm mb-4 hover:text-text" @click="router.back()"> Retour</button>
<div v-if="garden"> <div v-if="garden">
<h1 class="text-2xl font-bold text-green mb-1">{{ garden.nom }}</h1> <div class="flex items-start justify-between mb-1">
<h1 class="text-2xl font-bold text-green">{{ garden.nom }}</h1>
</div>
<p class="text-text-muted text-sm mb-6"> <p class="text-text-muted text-sm mb-6">
{{ garden.type }} · {{ garden.exposition ?? 'exposition non définie' }} {{ garden.type }} · {{ garden.exposition ?? 'exposition non définie' }}
<span v-if="garden.sol_type"> · Sol : {{ garden.sol_type }}</span> <span v-if="garden.sol_type"> · Sol : {{ garden.sol_type }}</span>
@@ -39,21 +41,81 @@
class="w-full max-h-72 object-cover rounded-lg border border-bg-hard bg-bg-soft" /> class="w-full max-h-72 object-cover rounded-lg border border-bg-hard bg-bg-soft" />
</div> </div>
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3"> <!-- En-tête grille -->
Grille {{ garden.grille_largeur }}×{{ garden.grille_hauteur }} <div class="flex items-center justify-between mb-3">
</h2> <h2 class="text-text-muted text-xs uppercase tracking-widest">
Grille {{ garden.grille_largeur }}×{{ garden.grille_hauteur }}
</h2>
<div class="flex items-center gap-2">
<span v-if="editMode" class="text-xs text-orange">Cliquez pour activer/désactiver une zone</span>
<button @click="editMode = !editMode"
:class="['px-3 py-1 rounded-full text-xs font-bold border transition-all',
editMode ? 'bg-orange text-bg border-orange' : 'border-bg-soft text-text-muted hover:border-orange hover:text-orange']">
{{ editMode ? '✓ Terminer' : '✏️ Éditer zones' }}
</button>
</div>
</div>
<!-- Légende -->
<div class="flex flex-wrap gap-4 mb-3 text-[10px] text-text-muted font-bold uppercase tracking-wider">
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-bg border border-bg-soft inline-block"></span>Libre
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-green/20 border border-green/60 inline-block"></span>Planté
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-red/20 border border-red/40 inline-block"></span>Non cultivable
</span>
</div>
<div class="overflow-x-auto pb-2"> <div class="overflow-x-auto pb-2">
<div <div
class="grid gap-1 w-max" class="grid gap-1 w-max"
:style="`grid-template-columns: repeat(${garden.grille_largeur}, 52px)`" :style="`grid-template-columns: repeat(${garden.grille_largeur}, 56px)`"
> >
<div <div
v-for="cell in displayCells" :key="`${cell.row}-${cell.col}`" 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="[
:class="{ 'border-orange/60 bg-orange/10 text-orange': cell.etat === 'occupe' }" 'w-[56px] h-[56px] border rounded-md flex flex-col items-center justify-center text-[10px] select-none transition-all overflow-hidden',
:title="cell.libelle" editMode && cell.etat !== 'occupe' && !getCellPlanting(cell) ? 'cursor-pointer' : '',
getCellPlanting(cell)
? 'bg-green/10 border-green/60 text-green'
: cell.etat === 'non_cultivable'
? 'bg-red/10 border-red/30 text-red/50'
: 'bg-bg-soft border-bg-hard text-text-muted',
editMode && !getCellPlanting(cell) && cell.etat !== 'occupe'
? (cell.etat === 'non_cultivable' ? 'hover:bg-red/20 hover:border-red/60' : 'hover:border-orange hover:bg-orange/10')
: ''
]"
:title="getCellTitle(cell)"
@click="editMode && !getCellPlanting(cell) && cell.etat !== 'occupe' ? toggleNonCultivable(cell) : undefined"
> >
{{ cell.libelle }} <span :class="['font-mono leading-none', getCellPlanting(cell) ? 'text-[9px] font-bold' : '']">
{{ cell.libelle }}
</span>
<span v-if="getCellPlanting(cell)" class="text-[8px] text-green/80 leading-none mt-0.5 px-0.5 text-center truncate w-full">
{{ plantShortName(getCellPlanting(cell)!) }}
</span>
<span v-else-if="cell.etat === 'non_cultivable'" class="text-[9px] leading-none"></span>
</div>
</div>
</div>
<!-- Saving indicator -->
<div v-if="saving" class="mt-2 text-xs text-text-muted animate-pulse">Enregistrement</div>
<!-- Résumé plantations actives -->
<div v-if="activePlantings.length" class="mt-6">
<h3 class="text-text-muted text-xs uppercase tracking-widest mb-2">Plantations actives ({{ activePlantings.length }})</h3>
<div class="flex flex-wrap gap-2">
<div v-for="p in activePlantings" :key="p.id"
class="bg-bg-soft border border-green/30 rounded px-2 py-1 text-xs text-green flex items-center gap-1.5">
<span class="text-text-muted font-mono text-[10px]">
{{ plantCellLabel(p) }}
</span>
{{ plantName(p) }}
<span class="text-text-muted">· {{ p.statut }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -66,11 +128,17 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { gardensApi, type Garden, type GardenCell } from '@/api/gardens' import { gardensApi, type Garden, type GardenCell } from '@/api/gardens'
import { plantingsApi, type Planting } from '@/api/plantings'
import { plantsApi, type Plant } from '@/api/plants'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const garden = ref<Garden | null>(null) const garden = ref<Garden | null>(null)
const cells = ref<GardenCell[]>([]) const cells = ref<GardenCell[]>([])
const plantings = ref<Planting[]>([])
const plants = ref<Plant[]>([])
const editMode = ref(false)
const saving = ref(false)
const displayCells = computed(() => { const displayCells = computed(() => {
if (!garden.value) return [] if (!garden.value) return []
@@ -88,9 +156,87 @@ const displayCells = computed(() => {
return result return result
}) })
// Plantations actives (ni terminées ni échouées) pour ce jardin
const activePlantings = computed(() =>
plantings.value.filter(p =>
p.garden_id === garden.value?.id &&
p.statut !== 'termine' &&
p.statut !== 'echoue'
)
)
// Map cellId → Planting active
const activePlantingsByCellId = computed(() => {
const map = new Map<number, Planting>()
for (const p of activePlantings.value) {
if (p.cell_ids?.length) {
p.cell_ids.forEach(cid => map.set(cid, p))
} else if (p.cell_id) {
map.set(p.cell_id, p)
}
}
return map
})
function getCellPlanting(cell: GardenCell): Planting | undefined {
if (cell.id == null) return undefined
return activePlantingsByCellId.value.get(cell.id)
}
function plantName(p: Planting): string {
const plant = plants.value.find(pl => pl.id === p.variety_id)
return plant?.nom_commun ?? `Plante #${p.variety_id}`
}
function plantShortName(p: Planting): string {
return plantName(p).slice(0, 8)
}
function plantCellLabel(p: Planting): string {
const ids = p.cell_ids?.length ? p.cell_ids : (p.cell_id ? [p.cell_id] : [])
if (!ids.length) return '—'
return ids
.map(cid => cells.value.find(c => c.id === cid)?.libelle ?? `#${cid}`)
.join(', ')
}
function getCellTitle(cell: GardenCell): string {
const planting = getCellPlanting(cell)
if (planting) return `Planté : ${plantName(planting)} (${planting.statut})`
if (cell.etat === 'non_cultivable') return 'Non cultivable — cliquer pour rendre cultivable'
if (editMode.value) return 'Marquer comme non cultivable'
return cell.libelle ?? ''
}
async function toggleNonCultivable(cell: GardenCell) {
if (!garden.value?.id || saving.value) return
const newEtat = cell.etat === 'non_cultivable' ? 'libre' : 'non_cultivable'
saving.value = true
try {
if (cell.id) {
const updated = await gardensApi.updateCell(garden.value.id, cell.id, { ...cell, etat: newEtat })
const idx = cells.value.findIndex(c => c.id === cell.id)
if (idx !== -1) cells.value[idx] = updated
} else {
const created = await gardensApi.createCell(garden.value.id, {
col: cell.col, row: cell.row,
libelle: cell.libelle,
etat: newEtat,
garden_id: garden.value.id,
})
cells.value.push(created)
}
} catch { /* L'intercepteur affiche l'erreur */ }
finally { saving.value = false }
}
onMounted(async () => { onMounted(async () => {
const id = Number(route.params.id) const id = Number(route.params.id)
garden.value = await gardensApi.get(id) ;[garden.value, cells.value, plantings.value, plants.value] = await Promise.all([
cells.value = await gardensApi.cells(id) gardensApi.get(id),
gardensApi.cells(id),
plantingsApi.list(),
plantsApi.list(),
])
}) })
</script> </script>

View File

@@ -1,169 +1,243 @@
<template> <template>
<div class="p-4 max-w-5xl mx-auto"> <div class="p-4 max-w-[1800px] mx-auto space-y-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-green">🪴 Jardins</h1> <div>
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90" <h1 class="text-3xl font-bold text-green tracking-tight">Mes Jardins</h1>
@click="openCreate">+ Nouveau</button> <p class="text-text-muted text-xs mt-1">Gérez vos espaces de culture et leurs dimensions.</p>
</div>
<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 group">
<div class="flex-1 cursor-pointer" @click="router.push(`/jardins/${g.id}`)">
<div class="text-text font-medium group-hover:text-green transition-colors">{{ g.nom }}</div>
<div class="text-text-muted text-xs mt-1">
{{ typeLabel(g.type) }} · {{ g.grille_largeur }}×{{ g.grille_hauteur }} cases
<span v-if="g.exposition"> · {{ g.exposition }}</span>
<span v-if="g.carre_potager && g.carre_x_cm != null && g.carre_y_cm != null">
· Carré potager {{ g.carre_x_cm }}×{{ g.carre_y_cm }} cm
</span>
<span v-if="g.longueur_m != null && g.largeur_m != null"> · {{ g.longueur_m }}×{{ g.largeur_m }} m</span>
<span v-if="g.surface_m2 != null"> · {{ g.surface_m2 }} m²</span>
</div>
</div> </div>
<button @click="startEdit(g)" class="text-yellow text-xs hover:underline px-2">Édit.</button> <button class="btn-primary flex items-center gap-2" @click="openCreate">
<button @click="store.remove(g.id!)" class="text-text-muted hover:text-red text-sm px-2"></button> <span class="text-lg leading-none">+</span> Nouveau jardin
</button>
</div> </div>
<div v-if="!store.loading && !store.gardens.length" class="text-text-muted text-sm text-center py-8"> <div v-if="store.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
Aucun jardin. Créez-en un ! <div v-for="i in 4" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
</div> </div>
<!-- Modal création / édition --> <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 3xl:grid-cols-6 gap-6">
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm"> <div v-for="g in store.gardens" :key="g.id"
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-4xl border border-bg-soft max-h-[90vh] overflow-y-auto"> class="card-jardin flex flex-col justify-between group relative overflow-hidden h-full min-h-[200px]">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier le jardin' : 'Nouveau jardin' }}</h2>
<form @submit.prevent="submit" class="grid grid-cols-1 lg:grid-cols-2 gap-3"> <!-- Image de fond subtile si dispo -->
<div v-if="g.photo_parcelle" class="absolute inset-0 opacity-10 pointer-events-none">
<img :src="g.photo_parcelle" class="w-full h-full object-cover" />
</div>
<div class="relative z-10">
<div class="flex items-start justify-between mb-4">
<div class="min-w-0 flex-1">
<h2 class="text-text font-bold text-xl group-hover:text-green transition-colors cursor-pointer truncate"
@click="router.push(`/jardins/${g.id}`)">
{{ g.nom }}
</h2>
<div class="flex flex-wrap items-center gap-2 mt-1">
<span class="badge badge-yellow">{{ typeLabel(g.type) }}</span>
<span v-if="g.exposition" class="badge badge-blue">{{ g.exposition }}</span>
</div>
</div>
<div class="flex gap-1 shrink-0 ml-2">
<button @click="startEdit(g)" class="p-2 text-text-muted hover:text-yellow transition-colors" title="Modifier">
</button>
<button @click="removeGarden(g.id!)" class="p-2 text-text-muted hover:text-red transition-colors" title="Supprimer">
</button>
</div>
</div>
<p v-if="g.description" class="text-text-muted text-sm mb-4 line-clamp-2 italic leading-relaxed">
{{ g.description }}
</p>
<div class="grid grid-cols-2 gap-4 py-3 border-t border-bg-hard/50">
<div>
<div class="text-[10px] text-text-muted uppercase font-bold tracking-widest">Grille</div>
<div class="text-sm text-text font-mono">
{{ g.grille_largeur }}×{{ g.grille_hauteur }} <span class="text-text-muted text-[10px]">cases</span>
</div>
</div>
<div>
<div class="text-[10px] text-text-muted uppercase font-bold tracking-widest">Surface</div>
<div class="text-sm text-text font-mono">
{{ g.surface_m2 || '--' }} <span class="text-text-muted text-[10px]"></span>
</div>
</div>
</div>
</div>
<button @click="router.push(`/jardins/${g.id}`)"
class="btn-outline w-full mt-4 border-green/20 text-green group-hover:bg-green/10 flex items-center justify-center gap-2 font-bold uppercase text-[10px] tracking-widest">
Ouvrir le plan
</button>
</div>
</div>
<div v-if="!store.loading && !store.gardens.length" class="card-jardin text-center py-16 opacity-50">
<div class="text-4xl mb-4">🪴</div>
<p class="text-text-muted text-sm">Vous n'avez pas encore créé de jardin.</p>
<button class="text-green hover:underline mt-2 font-bold" @click="openCreate">Commencer maintenant</button>
</div>
<!-- Modal création / édition ÉLARGIE -->
<div v-if="showForm" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-3xl p-8 w-full max-w-6xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-8 border-b border-bg-soft pb-6">
<div> <div>
<label class="text-text-muted text-xs block mb-1">Nom *</label> <h2 class="text-text font-black text-2xl uppercase tracking-tighter">{{ editId ? 'Modifier l\'espace' : 'Nouvel espace de culture' }}</h2>
<input v-model="form.nom" required <p class="text-text-muted text-xs mt-1 italic">Configurez les paramètres physiques et géographiques de votre jardin.</p>
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 class="lg:col-span-2"> <button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-2xl"></button>
<label class="text-text-muted text-xs block mb-1">Description</label> </div>
<textarea v-model="form.description" rows="2"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none resize-none" /> <form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
</div> <!-- Colonne 1 : Identité -->
<div> <div class="space-y-6">
<label class="text-text-muted text-xs block mb-1">Type</label> <h3 class="text-yellow font-bold text-xs uppercase tracking-widest flex items-center gap-2">
<select v-model="form.type" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm"> <span class="w-2 h-2 rounded-full bg-yellow"></span> Identité
<option value="plein_air">Plein air</option> </h3>
<option value="serre">Serre</option>
<option value="tunnel">Tunnel</option>
<option value="bac">Bac / Pot</option>
</select>
</div>
<div class="bg-bg rounded border border-bg-hard p-3 lg:col-span-2">
<label class="inline-flex items-center gap-2 text-sm text-text">
<input v-model="form.carre_potager" type="checkbox" class="accent-green" />
Carré potager
</label>
<p class="text-text-muted text-[11px] mt-1">Active les dimensions X/Y en centimètres pour un bac carré.</p>
</div>
<div v-if="form.carre_potager" class="grid grid-cols-1 sm:grid-cols-2 gap-3 lg:col-span-2">
<div> <div>
<label class="text-text-muted text-xs block mb-1">Dimension X (cm)</label> <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Nom de l'espace *</label>
<input v-model.number="form.carre_x_cm" type="number" min="1" step="1" <input v-model="form.nom" required placeholder="Ex: Serre de semis"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" /> class="w-full bg-bg border border-bg-soft rounded-2xl px-4 py-4 text-text text-sm focus:border-green outline-none transition-all shadow-inner" />
</div> </div>
<div> <div>
<label class="text-text-muted text-xs block mb-1">Dimension Y (cm)</label> <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Description libre</label>
<input v-model.number="form.carre_y_cm" type="number" min="1" step="1" <textarea v-model="form.description" rows="1" placeholder="Notes sur l'exposition réelle, l'historique..."
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" /> @input="autoResize"
class="w-full bg-bg border border-bg-soft rounded-2xl px-4 py-4 text-text text-sm focus:border-green outline-none resize-none transition-all shadow-inner overflow-hidden" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Type</label>
<select v-model="form.type" class="w-full bg-bg border border-bg-soft rounded-2xl px-4 py-3 text-text text-sm outline-none focus:border-green appearance-none">
<option value="plein_air">Plein air</option>
<option value="serre">Serre</option>
<option value="tunnel">Tunnel</option>
<option value="bac">Bac / Pot</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Exposition</label>
<select v-model="form.exposition" class="w-full bg-bg border border-bg-soft rounded-2xl px-4 py-3 text-text text-sm outline-none focus:border-green appearance-none">
<option value="">— Choisir —</option>
<option value="Nord">Nord</option>
<option value="Est">Est</option>
<option value="Sud">Sud</option>
<option value="Ouest">Ouest</option>
<option value="Sud-Est">Sud-Est</option>
<option value="Sud-Ouest">Sud-Ouest</option>
</select>
</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <!-- Colonne 2 : Géométrie & Grille -->
<label class="text-text-muted text-xs block mb-1">Largeur grille</label> <div class="space-y-6">
<input v-model.number="form.grille_largeur" type="number" min="1" max="30" <h3 class="text-aqua font-bold text-xs uppercase tracking-widest flex items-center gap-2">
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" /> <span class="w-2 h-2 rounded-full bg-aqua"></span> Géométrie & Grille
</h3>
<div class="bg-bg-soft/30 rounded-3xl p-6 border border-bg-soft space-y-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Colonnes (X)</label>
<input v-model.number="form.grille_largeur" type="number" min="1" max="30"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm font-mono" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Lignes (Y)</label>
<input v-model.number="form.grille_hauteur" type="number" min="1" max="30"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm font-mono" />
</div>
</div>
<div class="pt-4 border-t border-bg-hard">
<label class="inline-flex items-center gap-3 text-sm text-text cursor-pointer group">
<div class="relative">
<input v-model="form.carre_potager" type="checkbox" class="sr-only peer" />
<div class="w-12 h-6 bg-bg-hard rounded-full peer peer-checked:bg-green transition-colors"></div>
<div class="absolute left-1 top-1 w-4 h-4 bg-text-muted peer-checked:bg-bg peer-checked:translate-x-6 rounded-full transition-all"></div>
</div>
<span class="font-bold uppercase text-[10px] tracking-widest group-hover:text-green">Carré potager spécialisé</span>
</label>
</div>
<div v-if="form.carre_potager" class="grid grid-cols-2 gap-4 animate-fade-in">
<div>
<label class="text-text-muted text-[10px] block mb-1 font-bold">X (cm)</label>
<input v-model.number="form.carre_x_cm" type="number" min="1"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm" />
</div>
<div>
<label class="text-text-muted text-[10px] block mb-1 font-bold">Y (cm)</label>
<input v-model.number="form.carre_y_cm" type="number" min="1"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm" />
</div>
</div>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Hauteur grille</label> <div class="grid grid-cols-2 gap-4">
<input v-model.number="form.grille_hauteur" type="number" min="1" max="30" <div>
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" /> <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Surface (m²)</label>
<input v-model.number="form.surface_m2" type="number" step="0.1" placeholder="Auto"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm shadow-inner font-mono" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Sol</label>
<select v-model="form.sol_type" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none appearance-none">
<option value="">— Type —</option>
<option value="argileux">Argileux</option>
<option value="limoneux">Limoneux</option>
<option value="sableux">Sableux</option>
<option value="calcaire">Calcaire</option>
<option value="humifère">Humifère</option>
</select>
</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 lg:col-span-2">
<div> <!-- Colonne 3 : Localisation & Photo -->
<label class="text-text-muted text-xs block mb-1">Longueur (m)</label> <div class="space-y-6">
<input v-model.number="form.longueur_m" type="number" min="0" step="0.1" <h3 class="text-orange font-bold text-xs uppercase tracking-widest flex items-center gap-2">
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" /> <span class="w-2 h-2 rounded-full bg-orange"></span> Localisation & Visuel
</h3>
<div class="space-y-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-2">Adresse / Repère</label>
<input v-model="form.adresse" placeholder="Coordonnées ou adresse..."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-green shadow-inner" />
</div>
<div class="grid grid-cols-2 gap-4">
<input v-model.number="form.latitude" type="number" step="0.000001" placeholder="Lat."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-xs font-mono" />
<input v-model.number="form.longitude" type="number" step="0.000001" placeholder="Long."
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-xs font-mono" />
</div>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Largeur (m)</label> <div class="bg-bg-soft/30 rounded-3xl p-4 border border-bg-soft">
<input v-model.number="form.largeur_m" type="number" min="0" step="0.1" <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-3">Photo de l'espace</label>
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" /> <div class="relative group aspect-video rounded-2xl overflow-hidden bg-bg border-2 border-dashed border-bg-soft flex items-center justify-center cursor-pointer hover:border-green transition-all">
</div> <input type="file" accept="image/*" @change="onPhotoSelected" class="absolute inset-0 opacity-0 cursor-pointer z-20" />
<div> <img v-if="photoPreview" :src="photoPreview" class="absolute inset-0 w-full h-full object-cover" />
<label class="text-text-muted text-xs block mb-1">Surface ()</label> <div v-else class="text-center">
<input v-model.number="form.surface_m2" type="number" min="0" step="0.1" <span class="text-3xl block mb-2">📸</span>
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" /> <span class="text-[10px] font-bold uppercase text-text-muted">Importer une photo</span>
</div>
</div>
</div> </div>
</div> </div>
<div class="lg:col-span-2">
<label class="text-text-muted text-xs block mb-1">Photo parcelle (image)</label> <!-- Actions -->
<input type="file" accept="image/*" @change="onPhotoSelected" <div class="md:col-span-2 xl:col-span-3 flex justify-between items-center pt-8 border-t border-bg-soft mt-4">
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" /> <button type="button" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-6" @click="closeForm">Abandonner</button>
<div v-if="photoPreview" class="mt-2"> <div class="flex gap-4">
<img :src="photoPreview" alt="Prévisualisation parcelle" <button type="submit" class="btn-primary px-12 py-4 text-base shadow-xl" :disabled="submitting">
class="w-full max-h-44 object-cover rounded border border-bg-hard bg-bg-soft" /> {{ submitting ? 'Enregistrement…' : (editId ? 'Sauvegarder les modifications' : 'Créer cet espace de culture') }}
</button>
</div> </div>
</div> </div>
<div class="lg:col-span-2">
<label class="text-text-muted text-xs block mb-1">Adresse / localisation</label>
<input v-model="form.adresse"
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-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 lg:col-span-2">
<div>
<label class="text-text-muted text-xs block mb-1">Latitude</label>
<input v-model.number="form.latitude" type="number" step="0.000001"
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">Longitude</label>
<input v-model.number="form.longitude" type="number" step="0.000001"
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">Altitude (m)</label>
<input v-model.number="form.altitude" type="number" step="1"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 lg:col-span-2">
<div>
<label class="text-text-muted text-xs block mb-1">Exposition</label>
<select v-model="form.exposition" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value=""></option>
<option value="Nord">Nord</option>
<option value="Est">Est</option>
<option value="Sud">Sud</option>
<option value="Ouest">Ouest</option>
<option value="Sud-Est">Sud-Est</option>
<option value="Sud-Ouest">Sud-Ouest</option>
</select>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Type de sol</label>
<select v-model="form.sol_type" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value=""></option>
<option value="argileux">Argileux</option>
<option value="limoneux">Limoneux</option>
<option value="sableux">Sableux</option>
<option value="calcaire">Calcaire</option>
<option value="humifère">Humifère</option>
<option value="mixte">Mixte</option>
</select>
</div>
</div>
<div class="flex gap-2 mt-2 lg:col-span-2">
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">
{{ editId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="closeForm">Annuler</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -175,10 +249,13 @@ import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useGardensStore } from '@/stores/gardens' import { useGardensStore } from '@/stores/gardens'
import { gardensApi, type Garden } from '@/api/gardens' import { gardensApi, type Garden } from '@/api/gardens'
import { useToast } from '@/composables/useToast'
const router = useRouter() const router = useRouter()
const store = useGardensStore() const store = useGardensStore()
const toast = useToast()
const showForm = ref(false) const showForm = ref(false)
const submitting = ref(false)
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const photoFile = ref<File | null>(null) const photoFile = ref<File | null>(null)
const photoPreview = ref('') const photoPreview = ref('')
@@ -244,7 +321,16 @@ function onPhotoSelected(event: Event) {
} }
} }
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
async function submit() { async function submit() {
if (submitting.value) return
submitting.value = true
const autoLongueur = const autoLongueur =
form.carre_potager && form.carre_x_cm != null form.carre_potager && form.carre_x_cm != null
? Number((form.carre_x_cm / 100).toFixed(2)) ? Number((form.carre_x_cm / 100).toFixed(2))
@@ -280,19 +366,46 @@ async function submit() {
sol_type: form.sol_type || undefined, sol_type: form.sol_type || undefined,
} }
let saved: Garden try {
if (editId.value) { let saved: Garden
saved = await store.update(editId.value, payload) if (editId.value) {
} else { saved = await store.update(editId.value, payload)
saved = await store.create(payload) } else {
} saved = await store.create(payload)
}
if (photoFile.value && saved.id) { if (photoFile.value && saved.id) {
await gardensApi.uploadPhoto(saved.id, photoFile.value) try {
await store.fetchAll() await gardensApi.uploadPhoto(saved.id, photoFile.value)
await store.fetchAll()
} catch {
toast.warning('Jardin sauvegardé mais la photo n\'a pas pu être uploadée')
}
}
toast.success(editId.value ? 'Jardin modifié avec succès' : 'Jardin créé avec succès')
closeForm()
} catch {
// L'intercepteur Axios affiche déjà le message d'erreur
} finally {
submitting.value = false
} }
closeForm()
} }
onMounted(() => store.fetchAll()) async function removeGarden(id: number) {
try {
await store.remove(id)
toast.success('Jardin supprimé')
} catch {
// L'intercepteur Axios affiche le message
}
}
onMounted(async () => {
try {
await store.fetchAll()
} catch {
toast.error('Impossible de charger les jardins')
}
})
</script> </script>

View File

@@ -1,91 +1,129 @@
<template> <template>
<div class="p-4 max-w-4xl mx-auto"> <div class="p-4 max-w-[1800px] mx-auto space-y-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-yellow">🔧 Outils</h1> <div>
<button @click="openCreate" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">+ Ajouter</button> <h1 class="text-3xl font-bold text-yellow tracking-tight">Outils de Jardinage</h1>
<p class="text-text-muted text-xs mt-1">Gérez votre inventaire, notices et vidéos d'utilisation.</p>
</div>
<button @click="openCreate" class="btn-primary !bg-yellow !text-bg flex items-center gap-2">
<span class="text-lg leading-none">+</span> Ajouter
</button>
</div> </div>
<div v-if="toolsStore.loading" class="text-text-muted text-sm">Chargement...</div> <div v-if="toolsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 3xl:grid-cols-6 gap-4">
<div v-else-if="!toolsStore.tools.length" class="text-text-muted text-sm py-4">Aucun outil enregistré.</div> <div v-for="i in 6" :key="i" class="card-jardin h-32 animate-pulse opacity-20"></div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> </div>
<div v-else-if="!toolsStore.tools.length" class="card-jardin text-center py-16 opacity-50 border-dashed">
<div class="text-4xl mb-4">🔧</div>
<p class="text-text-muted text-sm uppercase font-black tracking-widest">Aucun outil enregistré.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 3xl:grid-cols-6 gap-4">
<div v-for="t in toolsStore.tools" :key="t.id" <div v-for="t in toolsStore.tools" :key="t.id"
class="bg-bg-soft rounded-lg p-4 border border-bg-hard flex flex-col gap-2"> class="card-jardin group flex flex-col h-full hover:border-yellow/30 transition-all !p-3">
<div class="flex items-center justify-between">
<span class="text-text font-semibold">{{ t.nom }}</span> <div class="flex items-start justify-between mb-2">
<div class="flex gap-2"> <div class="min-w-0 flex-1">
<button @click="startEdit(t)" class="text-yellow text-xs hover:underline">Édit.</button> <h2 class="text-text font-bold text-base group-hover:text-yellow transition-colors truncate" :title="t.nom">{{ t.nom }}</h2>
<button @click="removeTool(t.id!)" class="text-red text-xs hover:underline">Suppr.</button> <span v-if="t.categorie" class="badge badge-yellow !text-[8px] mt-0.5">{{ t.categorie }}</span>
</div>
<div class="flex gap-2 shrink-0 ml-2">
<button @click="startEdit(t)" class="text-[10px] text-text-muted hover:text-yellow font-bold uppercase tracking-tighter transition-colors">Édit.</button>
<button @click="removeTool(t.id!)" class="text-[10px] text-text-muted hover:text-red font-bold uppercase tracking-tighter transition-colors">Suppr.</button>
</div> </div>
</div> </div>
<span v-if="t.categorie" class="text-xs text-yellow bg-yellow/10 rounded-full px-2 py-0.5 w-fit">{{ t.categorie }}</span>
<p v-if="t.description" class="text-text-muted text-xs">{{ t.description }}</p>
<p v-if="t.boutique_nom || t.prix_achat != null" class="text-text-muted text-xs">
<span v-if="t.boutique_nom">🛒 {{ t.boutique_nom }}</span>
<span v-if="t.prix_achat != null"> · 💶 {{ t.prix_achat }} </span>
</p>
<a v-if="t.boutique_url" :href="t.boutique_url" target="_blank" rel="noopener noreferrer"
class="text-blue text-xs hover:underline truncate">🔗 Boutique</a>
<a v-if="t.video_url" :href="t.video_url" target="_blank" rel="noopener noreferrer"
class="text-aqua text-xs hover:underline truncate">🎬 Vidéo</a>
<p v-if="t.notice_texte" class="text-text-muted text-xs whitespace-pre-line">{{ t.notice_texte }}</p>
<a v-else-if="t.notice_fichier_url" :href="t.notice_fichier_url" target="_blank" rel="noopener noreferrer"
class="text-aqua text-xs hover:underline truncate">📄 Notice (fichier)</a>
<div v-if="t.photo_url || t.video_url" class="mt-auto pt-2 space-y-2"> <p v-if="t.description" class="text-text-muted text-[11px] leading-snug line-clamp-2 mb-3 italic opacity-80">
<img v-if="t.photo_url" :src="t.photo_url" alt="photo outil" {{ t.description }}
class="w-full h-28 object-cover rounded border border-bg-hard bg-bg" /> </p>
<video v-if="t.video_url" :src="t.video_url" controls muted
class="w-full h-36 object-cover rounded border border-bg-hard bg-bg" /> <!-- Médias Compacts -->
<div v-if="t.photo_url || t.video_url" class="mb-3 rounded-lg overflow-hidden bg-bg-hard border border-bg-soft/30 relative aspect-video shrink-0">
<img v-if="t.photo_url" :src="t.photo_url" class="w-full h-full object-cover transition-transform group-hover:scale-105" />
<div v-if="t.video_url && !t.photo_url" class="w-full h-full flex items-center justify-center text-aqua opacity-30">
<span class="text-2xl">🎬</span>
</div>
<!-- Overlay vidéo icon si dispo -->
<div v-if="t.video_url && t.photo_url" class="absolute bottom-1 right-1 bg-black/60 rounded px-1 py-0.5 text-[8px] text-aqua font-bold uppercase">Vidéo</div>
</div>
<div class="mt-auto pt-2 border-t border-bg-hard/30">
<div class="flex items-center justify-between gap-2">
<div class="flex gap-2 text-[9px] font-bold text-text-muted truncate">
<span v-if="t.boutique_nom" class="truncate">🛒 {{ t.boutique_nom }}</span>
<span v-if="t.prix_achat != null" class="shrink-0 text-yellow">{{ t.prix_achat }}€</span>
</div>
<div class="flex gap-1">
<a v-if="t.boutique_url" :href="t.boutique_url" target="_blank"
class="p-1 hover:bg-blue/10 rounded transition-colors text-blue" title="Boutique">🔗</a>
<a v-if="t.video_url" :href="t.video_url" target="_blank"
class="p-1 hover:bg-aqua/10 rounded transition-colors text-aqua" title="Vidéo">🎬</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm"> <!-- Modal Formulaire -->
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft"> <div v-if="showForm" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="closeForm">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier l\'outil' : 'Nouvel outil' }}</h2> <div class="bg-bg-hard rounded-2xl p-6 w-full max-w-xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<form @submit.prevent="submitTool" class="flex flex-col gap-3"> <div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
<input v-model="form.nom" placeholder="Nom de l'outil *" required <h2 class="text-text font-bold text-xl">{{ editId ? 'Modifier l\'outil' : 'Nouvel outil' }}</h2>
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" /> <button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-xl"></button>
<select v-model="form.categorie" </div>
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow">
<option value="">Catégorie</option> <form @submit.prevent="submitTool" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<option value="beche">Bêche</option> <div class="md:col-span-2">
<option value="fourche">Fourche</option> <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom de l'outil *</label>
<option value="griffe">Griffe/Grelinette</option> <input v-model="form.nom" required placeholder="Grelinette, Sécateur..."
<option value="arrosage">Arrosage</option> class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none transition-all shadow-inner" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Catégorie</label>
<select v-model="form.categorie" class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-sm outline-none focus:border-yellow">
<option value="">— Choisir —</option>
<option value="beche">Bêche</option>
<option value="fourche">Fourche</option>
<option value="griffe">Griffe/Grelinette</option>
<option value="arrosage">Arrosage</option>
<option value="taille">Taille</option> <option value="taille">Taille</option>
<option value="autre">Autre</option> <option value="autre">Autre</option>
</select> </select>
<div class="grid grid-cols-2 gap-3">
<input v-model="form.boutique_nom" placeholder="Nom boutique"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
<input v-model.number="form.prix_achat" type="number" min="0" step="0.01" placeholder="Prix achat (€)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
</div> </div>
<input v-model="form.boutique_url" type="url" placeholder="URL boutique (https://...)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
<textarea v-model="form.description" placeholder="Description..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow resize-none h-16" />
<div> <div>
<label class="text-text-muted text-xs block mb-1">Photo de l'outil</label> <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Prix (€)</label>
<input type="file" accept="image/*" @change="onPhotoSelected" <input v-model.number="form.prix_achat" type="number" step="0.01"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" /> class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2.5 text-text text-sm outline-none focus:border-yellow" />
<img v-if="photoPreview" :src="photoPreview" alt="Prévisualisation photo"
class="mt-2 w-full h-28 object-cover rounded border border-bg-hard bg-bg" />
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Vidéo de l'outil</label> <div class="md:col-span-2">
<input type="file" accept="video/*" @change="onVideoSelected" <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Description</label>
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" /> <textarea v-model="form.description" rows="1"
<video v-if="videoPreview" :src="videoPreview" controls muted @input="autoResize"
class="mt-2 w-full h-36 object-cover rounded border border-bg-hard bg-bg" /> class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-2 text-text text-sm focus:border-yellow outline-none resize-none transition-all overflow-hidden" />
</div> </div>
<textarea v-model="form.notice_texte" placeholder="Notice (texte libre)..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow resize-none h-24" /> <!-- Upload Photo -->
<div class="flex gap-2 justify-end"> <div class="bg-bg-soft/30 p-3 rounded-xl border border-bg-soft">
<button type="button" @click="closeForm" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button> <label class="text-text-muted text-[9px] font-black uppercase tracking-widest block mb-2">Photo de l'outil</label>
<button type="submit" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"> <input type="file" accept="image/*" @change="onPhotoSelected" class="text-[10px] text-text-muted w-full" />
{{ editId ? 'Enregistrer' : 'Créer' }} <img v-if="photoPreview" :src="photoPreview" class="mt-2 w-full h-24 object-cover rounded border border-bg-hard shadow-lg" />
</div>
<!-- Upload Vidéo -->
<div class="bg-bg-soft/30 p-3 rounded-xl border border-bg-soft">
<label class="text-text-muted text-[9px] font-black uppercase tracking-widest block mb-2">Vidéo démo</label>
<input type="file" accept="video/*" @change="onVideoSelected" class="text-[10px] text-text-muted w-full" />
<video v-if="videoPreview" :src="videoPreview" controls class="mt-2 w-full h-24 object-cover rounded border border-bg-hard shadow-lg" />
</div>
<div class="md:col-span-2 flex justify-between items-center pt-6 border-t border-bg-soft mt-2">
<button type="button" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold" @click="closeForm">Annuler</button>
<button type="submit" class="btn-primary !bg-yellow !text-bg px-8">
{{ editId ? 'Sauvegarder' : 'Enregistrer l\'outil' }}
</button> </button>
</div> </div>
</form> </form>
@@ -159,6 +197,12 @@ function onVideoSelected(event: Event) {
if (file) videoPreview.value = URL.createObjectURL(file) if (file) videoPreview.value = URL.createObjectURL(file)
} }
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
function startEdit(t: Tool) { function startEdit(t: Tool) {
editId.value = t.id! editId.value = t.id!
Object.assign(form, { Object.assign(form, {

View File

@@ -1,83 +1,120 @@
<template> <template>
<div class="p-4 max-w-3xl mx-auto"> <div class="p-4 max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-green">📆 Planning</h1> <div>
<!-- Navigateur 4 semaines --> <h1 class="text-3xl font-bold text-green tracking-tight">Planning</h1>
<div class="flex items-center gap-2"> <p class="text-text-muted text-xs mt-1">Visualisez et planifiez vos interventions sur 4 semaines.</p>
<button @click="prevPeriod"
class="text-xs text-text-muted border border-bg-hard rounded px-2 py-1 hover:text-text hover:border-text-muted">
Prev
</button>
<button @click="goToday"
class="text-xs text-green border border-green/30 rounded px-2 py-1 hover:bg-green/10">
Today
</button>
<button @click="nextPeriod"
class="text-xs text-text-muted border border-bg-hard rounded px-2 py-1 hover:text-text hover:border-text-muted">
Next
</button>
</div> </div>
</div>
<div class="text-text text-sm font-medium mb-3">{{ periodLabel }}</div>
<!-- En-tête jours --> <!-- Navigateur -->
<div class="grid grid-cols-7 gap-1 mb-2"> <div class="flex items-center gap-2 bg-bg-soft/30 p-1 rounded-xl border border-bg-soft">
<div v-for="dayName in dayHeaders" :key="dayName" class="text-center text-xs py-1 rounded text-text-muted"> <button @click="prevPeriod" class="btn-outline !py-1.5 !px-3 text-xs font-bold uppercase tracking-widest border-transparent hover:bg-bg-soft transition-all">Préc.</button>
{{ dayName }} <button @click="goToday" class="btn-primary !py-1.5 !px-4 text-xs !rounded-lg">Aujourd'hui</button>
<button @click="nextPeriod" class="btn-outline !py-1.5 !px-3 text-xs font-bold uppercase tracking-widest border-transparent hover:bg-bg-soft transition-all">Suiv.</button>
</div> </div>
</div> </div>
<!-- Grille 4 semaines --> <div class="flex items-center gap-3">
<div class="grid grid-cols-7 gap-1"> <div class="text-yellow font-bold text-sm tracking-widest uppercase bg-yellow/5 px-4 py-1 rounded-full border border-yellow/10">
<div v-for="d in periodDays" :key="d.iso" {{ periodLabel }}
@click="selectDay(d.iso)"
:class="['min-h-24 rounded-lg p-1 border transition-colors cursor-pointer',
d.isToday ? 'border-green/40 bg-green/5' : 'border-bg-hard bg-bg-soft',
selectedIso === d.iso ? 'ring-1 ring-yellow/60 border-yellow/40' : '']">
<div class="text-[11px] text-text-muted mb-1">
<span :class="d.isToday ? 'text-green font-bold' : ''">{{ d.dayNum }}</span>
<span v-if="d.showMonth" class="ml-1">{{ d.monthShort }}</span>
</div>
<div v-if="todoTasksByDay[d.iso]?.length" class="flex items-center gap-1 flex-wrap mb-1">
<span v-for="(t, i) in todoTasksByDay[d.iso].slice(0, 10)" :key="`${d.iso}-${t.id ?? i}`"
:class="['w-1.5 h-1.5 rounded-full', dotClass(t.priorite)]"></span>
</div>
<div v-for="t in tasksByDay[d.iso] || []" :key="t.id"
:class="['text-xs rounded px-1 py-0.5 mb-0.5 cursor-pointer hover:opacity-80 truncate',
priorityClass(t.priorite)]"
:title="t.titre">
{{ t.titre }}
</div>
<!-- Zone drop-cible (vide) -->
<div v-if="!(tasksByDay[d.iso]?.length)" class="text-text-muted text-xs text-center pt-2 opacity-40"></div>
</div> </div>
</div> </div>
<!-- Détail jour sélectionné --> <!-- Calendrier Grid -->
<div class="mt-4 bg-bg-soft rounded-lg p-3 border border-bg-hard"> <div class="space-y-4">
<div class="text-text text-sm font-semibold">{{ selectedLabel }}</div> <!-- En-tête jours -->
<div class="text-text-muted text-xs mt-0.5">{{ selectedTasks.length }} tâche(s) planifiée(s)</div> <div class="grid grid-cols-7 gap-3">
<div v-if="!selectedTasks.length" class="text-text-muted text-xs mt-2">Aucune tâche planifiée ce jour.</div> <div v-for="dayName in dayHeaders" :key="dayName"
<div v-else class="mt-2 space-y-1"> class="text-center text-[10px] font-black uppercase tracking-[0.2em] text-text-muted/60 pb-2">
<div v-for="t in selectedTasks" :key="t.id" {{ dayName }}
class="bg-bg rounded px-2 py-1 border border-bg-hard flex items-center gap-2"> </div>
<span :class="['w-2 h-2 rounded-full shrink-0', dotClass(t.priorite)]"></span> </div>
<span class="text-text text-xs flex-1 truncate">{{ t.titre }}</span>
<span :class="['text-[10px] px-1.5 py-0.5 rounded shrink-0', statutClass(t.statut)]">{{ t.statut }}</span> <!-- Grille 4 semaines -->
<div class="grid grid-cols-7 gap-3">
<div v-for="d in periodDays" :key="d.iso"
@click="selectDay(d.iso)"
:class="['min-h-32 card-jardin !p-2 flex flex-col group relative cursor-pointer border-2 transition-all',
d.isToday ? 'border-green/40 bg-green/5' : 'border-bg-hard/50',
selectedIso === d.iso ? '!border-yellow bg-yellow/5 scale-[1.02] z-10 shadow-xl' : 'hover:border-bg-soft']">
<div class="flex items-center justify-between mb-2">
<span :class="['text-xs font-bold font-mono', d.isToday ? 'text-green' : 'text-text-muted']">{{ d.dayNum }}</span>
<span v-if="d.showMonth" class="text-[9px] font-black uppercase tracking-tighter text-aqua">{{ d.monthShort }}</span>
</div>
<div class="flex-1 space-y-1 overflow-hidden">
<div v-for="t in tasksByDay[d.iso]?.slice(0, 3)" :key="t.id"
:class="['text-[9px] font-bold uppercase tracking-tighter rounded px-1.5 py-0.5 truncate border', priorityBorderClass(t.priorite)]">
{{ t.titre }}
</div>
<div v-if="tasksByDay[d.iso]?.length > 3" class="text-[9px] text-text-muted font-bold text-center pt-1">
+ {{ tasksByDay[d.iso].length - 3 }} autres
</div>
</div>
<!-- Indicateurs points si plein -->
<div v-if="todoTasksByDay[d.iso]?.length" class="absolute bottom-2 right-2 flex gap-0.5">
<span v-for="(t, i) in todoTasksByDay[d.iso].slice(0, 3)" :key="i"
:class="['w-1 h-1 rounded-full', dotClass(t.priorite)]"></span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Tâches sans date --> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8 pt-4">
<div class="mt-6"> <!-- Détail jour sélectionné -->
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">Sans date</h2> <section class="lg:col-span-2 space-y-4">
<div v-if="!unscheduled.length" class="text-text-muted text-xs pl-2">Toutes les tâches ont une échéance.</div> <h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<div v-for="t in unscheduled" :key="t.id" <span class="w-2 h-2 rounded-full bg-yellow"></span>
class="bg-bg-soft rounded-lg p-2 mb-1 border border-bg-hard flex items-center gap-2"> Détails du {{ selectedLabel }}
<span :class="['text-xs w-2 h-2 rounded-full flex-shrink-0', dotClass(t.priorite)]"></span> </h2>
<span class="text-text text-sm flex-1 truncate">{{ t.titre }}</span>
<span :class="['text-xs px-1.5 py-0.5 rounded', statutClass(t.statut)]">{{ t.statut }}</span> <div v-if="!selectedTasks.length" class="card-jardin text-center py-12 opacity-40 border-dashed">
</div> <p class="text-text-muted text-sm italic">Libre comme l'air ! Aucune tâche pour cette date. 🍃</p>
</div>
<div v-else class="space-y-3">
<div v-for="t in selectedTasks" :key="t.id"
class="card-jardin flex items-center gap-4 group">
<div :class="['w-1.5 h-10 rounded-full shrink-0', dotClass(t.priorite)]"></div>
<div class="flex-1 min-w-0">
<div class="text-text font-bold text-sm">{{ t.titre }}</div>
<div class="flex items-center gap-3 mt-1">
<span :class="['badge !text-[9px]', statutClass(t.statut)]">{{ t.statut?.replace('_', ' ') }}</span>
<span class="text-[10px] text-text-muted uppercase font-bold tracking-tighter opacity-60">Priorité {{ t.priorite }}</span>
</div>
</div>
<div class="text-green text-xl opacity-0 group-hover:opacity-100 transition-all"></div>
</div>
</div>
</section>
<!-- Tâches sans date -->
<section class="space-y-4">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-orange"></span>
À planifier
</h2>
<div v-if="!unscheduled.length" class="card-jardin text-center py-12 opacity-30 border-dashed">
<p class="text-[10px] font-bold uppercase">Tout est en ordre</p>
</div>
<div class="space-y-2">
<div v-for="t in unscheduled" :key="t.id"
class="card-jardin !p-3 flex flex-col gap-2 hover:border-orange/30 transition-colors">
<div class="flex items-start justify-between gap-2">
<span class="text-text font-bold text-xs flex-1 line-clamp-2">{{ t.titre }}</span>
<div :class="['w-2 h-2 rounded-full shrink-0 mt-1', dotClass(t.priorite)]"></div>
</div>
<div class="flex justify-between items-center">
<span :class="['badge !text-[8px]', statutClass(t.statut)]">{{ t.statut }}</span>
<button class="text-[10px] text-orange font-bold uppercase hover:underline">Planifier</button>
</div>
</div>
</div>
</section>
</div> </div>
</div> </div>
</template> </template>
@@ -185,19 +222,22 @@ function selectDay(iso: string) {
selectedIso.value = iso selectedIso.value = iso
} }
const priorityClass = (p: string) => ({ const priorityBorderClass = (p: string) => ({
haute: 'bg-red/20 text-red', haute: 'border-red/30 text-red bg-red/5',
normale: 'bg-yellow/20 text-yellow', normale: 'border-yellow/30 text-yellow bg-yellow/5',
basse: 'bg-bg-hard text-text-muted', basse: 'border-bg-soft text-text-muted bg-bg-hard/50',
}[p] || 'bg-bg-hard text-text-muted') }[p] || 'border-bg-soft text-text-muted')
const dotClass = (p: string) => ({ const dotClass = (p: string) => ({
haute: 'bg-red', normale: 'bg-yellow', basse: 'bg-text-muted', haute: 'bg-red shadow-[0_0_5px_rgba(251,73,52,0.5)]',
normale: 'bg-yellow shadow-[0_0_5px_rgba(250,189,47,0.5)]',
basse: 'bg-text-muted',
}[p] || 'bg-text-muted') }[p] || 'bg-text-muted')
const statutClass = (s: string) => ({ const statutClass = (s: string) => ({
a_faire: 'bg-blue/20 text-blue', en_cours: 'bg-green/20 text-green', a_faire: 'badge-blue',
fait: 'bg-text-muted/20 text-text-muted', en_cours: 'badge-green',
fait: 'badge-text-muted',
}[s] || '') }[s] || '')
onMounted(() => store.fetchAll()) onMounted(() => store.fetchAll())

View File

@@ -1,18 +1,20 @@
<template> <template>
<div class="p-4 max-w-5xl mx-auto"> <div class="p-4 max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-green">🌱 Plantations</h1> <div>
<button @click="showCreate = true" <h1 class="text-3xl font-bold text-green tracking-tight">Plantations</h1>
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"> <p class="text-text-muted text-xs mt-1">Suivi de vos cultures, de la plantation à la récolte.</p>
+ Nouvelle </div>
<button @click="showCreate = true" class="btn-primary flex items-center gap-2">
<span class="text-lg leading-none">+</span> Nouvelle
</button> </button>
</div> </div>
<!-- Filtres statut --> <!-- Filtres statut -->
<div class="flex gap-2 mb-4 flex-wrap"> <div class="flex gap-2 mb-4 bg-bg-soft/30 p-1 rounded-full w-fit border border-bg-soft">
<button v-for="s in statuts" :key="s.val" @click="filterStatut = s.val" <button v-for="s in statuts" :key="s.val" @click="filterStatut = s.val"
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors', :class="['px-4 py-1.5 rounded-full text-xs font-bold transition-all',
filterStatut === s.val ? 'bg-blue text-bg' : 'bg-bg-soft text-text-muted hover:text-text']"> filterStatut === s.val ? 'bg-yellow text-bg shadow-lg' : 'text-text-muted hover:text-text']">
{{ s.label }} {{ s.label }}
</button> </button>
</div> </div>
@@ -22,79 +24,93 @@
Aucune plantation enregistrée. Aucune plantation enregistrée.
</div> </div>
<div v-for="p in filtered" :key="p.id" <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6">
class="bg-bg-soft rounded-xl mb-3 border border-bg-hard overflow-hidden"> <div v-for="p in filtered" :key="p.id"
<!-- En-tête plantation --> class="card-jardin group flex flex-col justify-between">
<div class="p-4 flex items-start gap-3">
<div class="flex-1"> <div>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-start justify-between mb-3">
<span class="text-text font-semibold">{{ plantName(p.variety_id) }}</span> <div>
<span class="text-text-muted text-xs"> {{ gardenName(p.garden_id) }}</span> <h2 class="text-text font-bold text-lg group-hover:text-green transition-colors">{{ plantName(p.variety_id) }}</h2>
<span :class="['text-xs px-2 py-0.5 rounded-full font-medium', statutClass(p.statut)]">{{ p.statut }}</span> <div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="badge badge-yellow">{{ gardenName(p.garden_id) }}</span>
<span :class="['badge', statutClass(p.statut)]">{{ p.statut?.replace('_', ' ') }}</span>
<template v-if="p.cell_ids?.length">
<span v-for="cid in p.cell_ids" :key="cid" class="badge bg-orange/20 text-orange">
📍 {{ cellLabelById(p.garden_id, cid) }}
</span>
</template>
<span v-else-if="p.cell_id" class="badge bg-orange/20 text-orange">📍 {{ cellLabelById(p.garden_id, p.cell_id) }}</span>
</div>
</div>
<div class="flex gap-1">
<button @click="startEdit(p)" class="p-1.5 text-text-muted hover:text-yellow transition-colors" title="Modifier"></button>
<button @click="store.remove(p.id!)" class="p-1.5 text-text-muted hover:text-red transition-colors" title="Supprimer"></button>
</div>
</div> </div>
<div class="text-text-muted text-xs mt-1 flex gap-3 flex-wrap">
<span>{{ p.quantite }} plant(s)</span> <div class="flex flex-wrap gap-y-2 gap-x-4 text-[11px] text-text-muted font-bold uppercase tracking-wider mb-4 opacity-80">
<span v-if="p.date_plantation">🌱 {{ fmtDate(p.date_plantation) }}</span> <span class="flex items-center gap-1.5">📦 {{ p.quantite }} plants</span>
<span v-if="p.boutique_nom">🛒 {{ p.boutique_nom }}</span> <span v-if="p.date_plantation" class="flex items-center gap-1.5">📅 {{ fmtDate(p.date_plantation) }}</span>
<span v-if="p.tarif_achat != null">💶 {{ p.tarif_achat }} </span> <span v-if="p.boutique_nom" class="flex items-center gap-1.5">🛒 {{ p.boutique_nom }}</span>
<span v-if="p.date_achat">🧾 {{ fmtDate(p.date_achat) }}</span>
<span v-if="p.notes">📝 {{ p.notes }}</span>
</div> </div>
<p v-if="p.notes" class="text-text-muted text-xs italic mb-4 line-clamp-2 bg-bg-hard/30 p-2 rounded-lg border-l-2 border-bg-soft">
{{ p.notes }}
</p>
</div> </div>
<div class="flex gap-2 items-center">
<div class="flex gap-2 pt-3 border-t border-bg-hard/50">
<button @click="toggleRecoltes(p.id!)" <button @click="toggleRecoltes(p.id!)"
:class="['text-xs px-2 py-1 rounded transition-colors', :class="['flex-1 btn-outline py-1.5 flex items-center justify-center gap-2 text-xs font-bold uppercase tracking-widest',
openRecoltes === p.id ? 'bg-aqua/20 text-aqua' : 'bg-bg-hard text-text-muted hover:text-aqua']"> openRecoltes === p.id ? 'bg-aqua text-bg border-aqua' : 'border-aqua/20 text-aqua hover:bg-aqua/10']">
🍅 Récoltes <span>🍅</span> Récoltes
</button> </button>
<button <button
class="text-xs px-2 py-1 rounded bg-blue/20 text-blue hover:bg-blue/30 transition-colors" class="btn-outline py-1.5 px-4 border-blue/20 text-blue hover:bg-blue/10 flex items-center justify-center gap-2 text-xs font-bold uppercase tracking-widest"
@click="openTaskFromTemplate(p)" @click="openTaskFromTemplate(p)"
> >
Tâche <span></span> Tâche
</button> </button>
<button @click="startEdit(p)" class="text-yellow text-xs hover:underline">Édit.</button>
<button @click="store.remove(p.id!)" class="text-text-muted hover:text-red text-sm ml-1"></button>
</div> </div>
</div>
<!-- Section récoltes (dépliable) --> <!-- Section récoltes (dépliable) -->
<div v-if="openRecoltes === p.id" class="border-t border-bg-hard px-4 py-3 bg-bg/50"> <div v-if="openRecoltes === p.id" class="mt-4 animate-fade-in space-y-3 bg-bg-hard/50 p-4 rounded-xl border border-aqua/10">
<div v-if="loadingRecoltes" class="text-text-muted text-xs py-2">Chargement...</div> <div v-if="loadingRecoltes" class="text-center py-4">
<div v-else> <div class="w-6 h-6 border-2 border-aqua/20 border-t-aqua rounded-full animate-spin mx-auto"></div>
<div v-if="!recoltesList.length" class="text-text-muted text-xs mb-2">Aucune récolte enregistrée.</div>
<div v-for="r in recoltesList" :key="r.id"
class="flex items-center gap-3 text-sm py-1 border-b border-bg-hard last:border-0">
<span class="text-aqua font-mono">{{ r.quantite }} {{ r.unite }}</span>
<span class="text-text-muted text-xs">{{ fmtDate(r.date_recolte) }}</span>
<span v-if="r.notes" class="text-text-muted text-xs flex-1 truncate">{{ r.notes }}</span>
<button @click="deleteRecolte(r.id!, p.id!)" class="text-text-muted hover:text-red text-xs ml-auto"></button>
</div> </div>
<div v-else>
<div v-if="!recoltesList.length" class="text-text-muted text-[10px] uppercase font-bold text-center py-2">Aucune récolte.</div>
<div v-for="r in recoltesList" :key="r.id"
class="flex items-center gap-3 text-sm py-2 border-b border-bg-hard last:border-0 group/row">
<span class="badge badge-green font-mono">{{ r.quantite }} {{ r.unite }}</span>
<span class="text-text-muted text-xs font-bold">{{ fmtDate(r.date_recolte) }}</span>
<span v-if="r.notes" class="text-text-muted text-xs italic flex-1 truncate">{{ r.notes }}</span>
<button @click="deleteRecolte(r.id!, p.id!)" class="text-text-muted hover:text-red opacity-0 group-hover/row:opacity-100 transition-opacity"></button>
</div>
<!-- Formulaire ajout récolte --> <!-- Formulaire ajout récolte -->
<form @submit.prevent="addRecolte(p.id!)" class="flex gap-2 mt-3 flex-wrap items-end"> <form @submit.prevent="addRecolte(p.id!)" class="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-bg-hard">
<div> <div class="col-span-1">
<label class="text-text-muted text-xs block mb-1">Quantité *</label> <input v-model.number="rForm.quantite" type="number" step="0.1" placeholder="Qté" required
<input v-model.number="rForm.quantite" type="number" step="0.1" min="0" required class="w-full bg-bg border border-bg-hard rounded-lg px-2 py-1.5 text-text text-xs focus:border-aqua outline-none" />
class="bg-bg border border-bg-hard rounded px-2 py-1 text-text text-xs w-20 focus:border-aqua outline-none" /> </div>
</div> <div class="col-span-1">
<div> <select v-model="rForm.unite"
<label class="text-text-muted text-xs block mb-1">Unité</label> class="w-full bg-bg border border-bg-hard rounded-lg px-2 py-1.5 text-text text-xs focus:border-aqua outline-none">
<select v-model="rForm.unite" <option>kg</option><option>g</option><option>unites</option><option>litres</option><option>bottes</option>
class="bg-bg border border-bg-hard rounded px-2 py-1 text-text text-xs focus:border-aqua outline-none"> </select>
<option>kg</option><option>g</option><option>unites</option><option>litres</option><option>bottes</option> </div>
</select> <div class="col-span-1">
</div> <input v-model="rForm.date_recolte" type="date" required
<div> class="w-full bg-bg border border-bg-hard rounded-lg px-2 py-1.5 text-text text-xs focus:border-aqua outline-none font-mono" />
<label class="text-text-muted text-xs block mb-1">Date *</label> </div>
<input v-model="rForm.date_recolte" type="date" required <button type="submit"
class="bg-bg border border-bg-hard rounded px-2 py-1 text-text text-xs focus:border-aqua outline-none" /> class="col-span-3 bg-aqua text-bg py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-widest hover:opacity-90">
</div> Enregistrer la récolte
<button type="submit" </button>
class="bg-aqua text-bg px-3 py-1 rounded text-xs font-semibold hover:opacity-90 self-end"> </form>
+ Ajouter </div>
</button>
</form>
</div> </div>
</div> </div>
</div> </div>
@@ -136,8 +152,9 @@
<label class="text-text-muted text-xs block mb-1">Description complémentaire (optionnel)</label> <label class="text-text-muted text-xs block mb-1">Description complémentaire (optionnel)</label>
<textarea <textarea
v-model="taskTemplateForm.extra_description" v-model="taskTemplateForm.extra_description"
rows="2" rows="1"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none" @input="autoResize"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none transition-all overflow-hidden"
/> />
</div> </div>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
@@ -156,111 +173,203 @@
</div> </div>
<!-- Modal création / édition plantation --> <!-- Modal création / édition plantation -->
<div v-if="showCreate" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" <div v-if="showCreate" class="fixed inset-0 bg-black/60 z-50 flex items-end sm:items-center justify-center sm:p-4"
@click.self="closeCreate"> @click.self="closeCreate">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft"> <div class="bg-bg-hard w-full sm:rounded-xl sm:max-w-2xl border-t sm:border border-bg-soft flex flex-col max-h-[92vh] sm:max-h-[90vh]">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier la plantation' : 'Nouvelle plantation' }}</h2>
<form @submit.prevent="createPlanting" class="flex flex-col gap-3"> <!-- En-tête fixe -->
<div> <div class="flex items-center justify-between px-5 pt-5 pb-4 border-b border-bg-soft/40 shrink-0">
<label class="text-text-muted text-xs block mb-1">Jardin *</label> <h2 class="text-text font-bold text-base">{{ editId ? 'Modifier la plantation' : 'Nouvelle plantation' }}</h2>
<select v-model.number="cForm.garden_id" required <button type="button" @click="closeCreate" class="text-text-muted hover:text-text text-xl leading-none"></button>
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green"> </div>
<option value="">Choisir un jardin</option>
<option v-for="g in gardensStore.gardens" :key="g.id" :value="g.id">{{ g.nom }}</option> <!-- Corps scrollable -->
</select> <form id="planting-form" @submit.prevent="createPlanting"
</div> class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<div>
<label class="text-text-muted text-xs block mb-1">Plante *</label> <!-- Jardin + grille zones (pleine largeur) -->
<select v-model.number="cForm.variety_id" required <div class="space-y-3">
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Choisir une plante</option>
<option v-for="p in plantsStore.plants" :key="p.id" :value="p.id">
{{ formatPlantLabel(p) }}
</option>
</select>
</div>
<div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="text-text-muted text-xs block mb-1">Quantité</label> <label class="text-text-muted text-xs block mb-1">Jardin *</label>
<input v-model.number="cForm.quantite" type="number" min="1" <select v-model.number="cForm.garden_id" required
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" /> class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Choisir un jardin</option>
<option v-for="g in gardensStore.gardens" :key="g.id" :value="g.id">{{ g.nom }}</option>
</select>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Date plantation</label> <!-- Grille des zones (multi-sélect) -->
<input v-model="cForm.date_plantation" type="date" <div v-if="cForm.garden_id" class="bg-bg/50 rounded-lg p-3 border border-bg-soft/40">
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" /> <div class="flex items-center justify-between mb-2">
<label class="text-text-muted text-xs font-bold uppercase tracking-wider">
Zones
<span class="text-text-muted/40 normal-case font-normal tracking-normal ml-1">(optionnel)</span>
</label>
<button v-if="cForm.cell_ids.length" type="button"
@click="cForm.cell_ids = []"
class="text-[10px] text-red/70 hover:text-red font-bold uppercase tracking-wider">
Effacer tout
</button>
</div>
<div v-if="loadingCells" class="flex justify-center py-3">
<div class="w-5 h-5 border-2 border-green/20 border-t-green rounded-full animate-spin"></div>
</div>
<div v-else class="space-y-2">
<div class="grid gap-1"
:style="{ gridTemplateColumns: `repeat(${selectedGarden?.grille_largeur || 4}, minmax(0, 1fr))` }">
<button
v-for="cell in displayCells" :key="`${cell.row}-${cell.col}`"
type="button"
:disabled="cell.etat === 'non_cultivable' || (cell.id != null && occupiedCellIds.has(cell.id)) || creatingCell"
@click="selectCell(cell)"
:title="cell.etat === 'non_cultivable' ? 'Non cultivable'
: (cell.id != null && occupiedCellIds.has(cell.id)) ? 'Zone occupée'
: (cell.libelle || `Col ${cell.col + 1}, Rg ${cell.row + 1}`)"
:class="[
'rounded py-1 px-0.5 text-[10px] font-mono transition-all border truncate min-h-6 text-center select-none',
cell.id != null && cForm.cell_ids.includes(cell.id)
? 'bg-green text-bg border-green font-bold'
: cell.etat === 'non_cultivable'
? 'bg-red/10 text-red/40 border-red/20 cursor-not-allowed'
: (cell.id != null && occupiedCellIds.has(cell.id))
? 'bg-bg/20 text-text-muted/30 border-bg-soft/20 cursor-not-allowed line-through'
: 'bg-bg border-bg-soft/60 text-text-muted hover:border-green hover:text-green cursor-pointer'
]">
{{ cell.libelle || `${String.fromCharCode(65 + cell.row)}${cell.col + 1}` }}
</button>
</div>
<div v-if="creatingCell" class="text-xs text-text-muted animate-pulse">Création</div>
<div v-else-if="cForm.cell_ids.length" class="flex flex-wrap gap-1 pt-0.5">
<span v-for="cid in cForm.cell_ids" :key="cid"
class="bg-green/20 text-green text-[10px] px-1.5 py-0.5 rounded font-mono">
{{ cellLabelById(cForm.garden_id, cid) }}
</span>
</div>
</div>
</div> </div>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Statut</label> <!-- 2 colonnes sur desktop -->
<select v-model="cForm.statut" <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green"> <!-- Colonne gauche : infos essentielles -->
<option value="prevu">Prévu</option> <div class="space-y-3">
<option value="en_cours">En cours</option> <div>
<option value="termine">Terminé</option> <label class="text-text-muted text-xs block mb-1">Plante *</label>
</select> <select v-model.number="cForm.variety_id" required
</div> class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<div class="grid grid-cols-2 gap-3"> <option value="">Choisir une plante</option>
<div> <option v-for="p in plantsStore.plants" :key="p.id" :value="p.id">
<label class="text-text-muted text-xs block mb-1">Nom boutique</label> {{ formatPlantLabel(p) }}
<input v-model="cForm.boutique_nom" type="text" placeholder="Ex: Graines Bocquet" </option>
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" /> </select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-text-muted text-xs block mb-1">Quantité</label>
<input v-model.number="cForm.quantite" type="number" min="1"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Date plantation</label>
<input v-model="cForm.date_plantation" type="date"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Statut</label>
<select v-model="cForm.statut"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="prevu">Prévu</option>
<option value="en_cours">En cours</option>
<option value="termine">Terminé</option>
</select>
</div>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Date achat</label> <!-- Colonne droite : infos achat + notes -->
<input v-model="cForm.date_achat" type="date" <div class="space-y-3">
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" /> <div class="grid grid-cols-2 gap-2">
<div>
<label class="text-text-muted text-xs block mb-1">Boutique</label>
<input v-model="cForm.boutique_nom" type="text" placeholder="Ex: Bocquet"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Date achat</label>
<input v-model="cForm.date_achat" type="date"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-text-muted text-xs block mb-1">Tarif ()</label>
<input v-model.number="cForm.tarif_achat" type="number" min="0" step="0.01" placeholder="0.00"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">URL boutique</label>
<input v-model="cForm.boutique_url" type="url" placeholder="https://..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Notes</label>
<textarea v-model="cForm.notes" rows="3" placeholder="Observations, variété, provenance…"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none" />
</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Tarif achat ()</label>
<input v-model.number="cForm.tarif_achat" type="number" min="0" step="0.01" placeholder="0.00"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">URL boutique</label>
<input v-model="cForm.boutique_url" type="url" placeholder="https://..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
</div>
</div>
<textarea v-model="cForm.notes" placeholder="Notes..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none h-16" />
<div class="flex gap-2 justify-end">
<button type="button" @click="closeCreate" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button type="submit" class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
{{ editId ? 'Enregistrer' : 'Créer' }}
</button>
</div>
</form> </form>
<!-- Pied fixe avec boutons -->
<div class="px-5 py-4 border-t border-bg-soft/40 shrink-0 flex gap-2 justify-end bg-bg-hard rounded-b-xl">
<button type="button" @click="closeCreate"
class="px-4 py-2 text-text-muted hover:text-text text-sm rounded-lg border border-bg-soft/50 hover:border-bg-soft transition-colors">
Annuler
</button>
<button type="submit" form="planting-form"
class="bg-green text-bg px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
:disabled="submitting">
{{ submitting ? 'Enregistrement…' : (editId ? 'Enregistrer' : 'Créer') }}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { usePlantingsStore } from '@/stores/plantings' import { usePlantingsStore } from '@/stores/plantings'
import { useGardensStore } from '@/stores/gardens' import { useGardensStore } from '@/stores/gardens'
import { usePlantsStore } from '@/stores/plants' import { usePlantsStore } from '@/stores/plants'
import type { Planting } from '@/api/plantings' import type { Planting } from '@/api/plantings'
import { gardensApi, type GardenCell } from '@/api/gardens'
import { recoltesApi, type Recolte } from '@/api/recoltes' import { recoltesApi, type Recolte } from '@/api/recoltes'
import { tasksApi, type Task } from '@/api/tasks' import { tasksApi, type Task } from '@/api/tasks'
import { formatPlantLabel } from '@/utils/plants' import { formatPlantLabel } from '@/utils/plants'
import { useToast } from '@/composables/useToast'
const store = usePlantingsStore() const store = usePlantingsStore()
const gardensStore = useGardensStore() const gardensStore = useGardensStore()
const plantsStore = usePlantsStore() const plantsStore = usePlantsStore()
const toast = useToast()
const showCreate = ref(false) const showCreate = ref(false)
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const filterStatut = ref('') const filterStatut = ref('')
const submitting = ref(false)
const openRecoltes = ref<number | null>(null) const openRecoltes = ref<number | null>(null)
const recoltesList = ref<Recolte[]>([]) const recoltesList = ref<Recolte[]>([])
const loadingRecoltes = ref(false) const loadingRecoltes = ref(false)
const templates = ref<Task[]>([]) const templates = ref<Task[]>([])
const showTaskTemplateModal = ref(false) const showTaskTemplateModal = ref(false)
const taskTarget = ref<Planting | null>(null) const taskTarget = ref<Planting | null>(null)
const gardenCells = ref<GardenCell[]>([])
const loadingCells = ref(false)
const creatingCell = ref(false)
const cellsCache = new Map<number, GardenCell[]>()
const statuts = [ const statuts = [
{ val: '', label: 'Toutes' }, { val: '', label: 'Toutes' },
@@ -274,7 +383,8 @@ const cForm = reactive({
garden_id: 0, variety_id: 0, quantite: 1, garden_id: 0, variety_id: 0, quantite: 1,
date_plantation: '', statut: 'prevu', date_plantation: '', statut: 'prevu',
boutique_nom: '', boutique_url: '', tarif_achat: undefined as number | undefined, date_achat: '', boutique_nom: '', boutique_url: '', tarif_achat: undefined as number | undefined, date_achat: '',
notes: '' notes: '',
cell_ids: [] as number[],
}) })
const rForm = reactive({ const rForm = reactive({
@@ -291,11 +401,102 @@ const filtered = computed(() =>
filterStatut.value ? store.plantings.filter(p => p.statut === filterStatut.value) : store.plantings filterStatut.value ? store.plantings.filter(p => p.statut === filterStatut.value) : store.plantings
) )
// Chargement des zones quand le jardin change dans le formulaire
watch(() => cForm.garden_id, async (newId, oldId) => {
// Réinitialiser les zones seulement si l'utilisateur change de jardin manuellement
if (oldId !== 0) cForm.cell_ids = []
gardenCells.value = []
if (!newId) return
if (cellsCache.has(newId)) {
gardenCells.value = cellsCache.get(newId)!
return
}
loadingCells.value = true
try {
const cells = await gardensApi.cells(newId)
gardenCells.value = cells
cellsCache.set(newId, cells)
} catch { /* silencieux */ }
finally { loadingCells.value = false }
})
const selectedGarden = computed(() =>
gardensStore.gardens.find(g => g.id === cForm.garden_id)
)
// Zones occupées par des plantations actives (hors récolté/échoué et hors édition en cours)
const occupiedCellIds = computed(() => {
const ids = new Set<number>()
for (const p of store.plantings) {
if (p.id === editId.value || p.statut === 'termine' || p.statut === 'echoue') continue
if (p.cell_ids?.length) {
p.cell_ids.forEach(id => ids.add(id))
} else if (p.cell_id) {
ids.add(p.cell_id)
}
}
return ids
})
// Grille virtuelle complète : cases DB + cases virtuelles générées depuis les dimensions
const displayCells = computed(() => {
if (!selectedGarden.value) return []
const { grille_largeur, grille_hauteur } = selectedGarden.value
const map = new Map(gardenCells.value.map(c => [`${c.row}-${c.col}`, c]))
const result: GardenCell[] = []
for (let row = 0; row < grille_hauteur; row++) {
for (let col = 0; col < grille_largeur; col++) {
result.push(map.get(`${row}-${col}`) ?? {
col, row,
libelle: `${String.fromCharCode(65 + row)}${col + 1}`,
etat: 'libre',
})
}
}
return result
})
function plantName(id: number) { function plantName(id: number) {
const p = plantsStore.plants.find(x => x.id === id) const p = plantsStore.plants.find(x => x.id === id)
return p ? formatPlantLabel(p) : `Plante #${id}` return p ? formatPlantLabel(p) : `Plante #${id}`
} }
function cellLabelById(gardenId: number, cellId: number): string {
const cells = cellsCache.get(gardenId)
if (!cells) return `Zone #${cellId}`
const cell = cells.find(c => c.id === cellId)
return cell ? (cell.libelle || `${String.fromCharCode(65 + cell.row)}${cell.col + 1}`) : `Zone #${cellId}`
}
async function selectCell(cell: GardenCell) {
if (creatingCell.value) return
if (cell.etat === 'non_cultivable') return
if (cell.id != null && occupiedCellIds.value.has(cell.id)) return
// Case déjà en DB : toggler dans cell_ids
if (cell.id != null) {
const idx = cForm.cell_ids.indexOf(cell.id)
if (idx !== -1) cForm.cell_ids.splice(idx, 1)
else cForm.cell_ids.push(cell.id)
return
}
// Case virtuelle (pas en DB) : la créer d'abord, puis l'ajouter
if (!cForm.garden_id) return
creatingCell.value = true
try {
const created = await gardensApi.createCell(cForm.garden_id, {
col: cell.col, row: cell.row,
libelle: cell.libelle,
etat: 'libre',
garden_id: cForm.garden_id,
})
const updated = [...gardenCells.value, created]
gardenCells.value = updated
cellsCache.set(cForm.garden_id, updated)
cForm.cell_ids.push(created.id!)
} catch { /* silencieux */ }
finally { creatingCell.value = false }
}
function gardenName(id: number) { function gardenName(id: number) {
return gardensStore.gardens.find(g => g.id === id)?.nom ?? `Jardin #${id}` return gardensStore.gardens.find(g => g.id === id)?.nom ?? `Jardin #${id}`
} }
@@ -320,15 +521,31 @@ async function toggleRecoltes(id: number) {
} }
async function addRecolte(plantingId: number) { async function addRecolte(plantingId: number) {
const created = await recoltesApi.create(plantingId, { ...rForm }) try {
recoltesList.value.push(created) const created = await recoltesApi.create(plantingId, { ...rForm })
Object.assign(rForm, { quantite: 1, unite: 'kg', date_recolte: new Date().toISOString().slice(0, 10) }) recoltesList.value.push(created)
Object.assign(rForm, { quantite: 1, unite: 'kg', date_recolte: new Date().toISOString().slice(0, 10) })
toast.success('Récolte enregistrée')
} catch {
// L'intercepteur Axios affiche le message
}
} }
async function deleteRecolte(id: number, plantingId: number) { async function deleteRecolte(id: number, _plantingId: number) {
if (!confirm('Supprimer cette récolte ?')) return if (!confirm('Supprimer cette récolte ?')) return
await recoltesApi.delete(id) try {
recoltesList.value = recoltesList.value.filter(r => r.id !== id) await recoltesApi.delete(id)
recoltesList.value = recoltesList.value.filter(r => r.id !== id)
toast.success('Récolte supprimée')
} catch {
// L'intercepteur Axios affiche le message
}
}
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
} }
function startEdit(p: typeof store.plantings[0]) { function startEdit(p: typeof store.plantings[0]) {
@@ -342,11 +559,21 @@ function startEdit(p: typeof store.plantings[0]) {
tarif_achat: p.tarif_achat, tarif_achat: p.tarif_achat,
date_achat: p.date_achat?.slice(0, 10) || '', date_achat: p.date_achat?.slice(0, 10) || '',
notes: p.notes || '', notes: p.notes || '',
cell_ids: p.cell_ids?.length ? [...p.cell_ids] : (p.cell_id ? [p.cell_id] : []),
}) })
showCreate.value = true showCreate.value = true
} }
function closeCreate() { showCreate.value = false; editId.value = null } function closeCreate() {
showCreate.value = false
editId.value = null
gardenCells.value = []
Object.assign(cForm, {
garden_id: 0, variety_id: 0, quantite: 1, date_plantation: '', statut: 'prevu',
boutique_nom: '', boutique_url: '', tarif_achat: undefined, date_achat: '',
notes: '', cell_ids: [],
})
}
async function loadTemplates() { async function loadTemplates() {
templates.value = await tasksApi.list({ statut: 'template' }) templates.value = await tasksApi.list({ statut: 'template' })
@@ -372,38 +599,55 @@ async function createTaskFromTemplate() {
if (!tpl) return if (!tpl) return
const extra = taskTemplateForm.extra_description.trim() const extra = taskTemplateForm.extra_description.trim()
const description = [tpl.description || '', extra].filter(Boolean).join('\n\n') const description = [tpl.description || '', extra].filter(Boolean).join('\n\n')
await tasksApi.create({ try {
titre: tpl.titre, await tasksApi.create({
description: description || undefined, titre: tpl.titre,
garden_id: taskTarget.value.garden_id, description: description || undefined,
planting_id: taskTarget.value.id, garden_id: taskTarget.value.garden_id,
priorite: tpl.priorite || 'normale', planting_id: taskTarget.value.id,
echeance: taskTemplateForm.echeance || undefined, priorite: tpl.priorite || 'normale',
recurrence: tpl.recurrence ?? null, echeance: taskTemplateForm.echeance || undefined,
frequence_jours: tpl.frequence_jours ?? null, recurrence: tpl.recurrence ?? null,
statut: 'a_faire', frequence_jours: tpl.frequence_jours ?? null,
}) statut: 'a_faire',
closeTaskTemplateModal() })
toast.success('Tâche créée depuis le template')
closeTaskTemplateModal()
} catch {
// L'intercepteur Axios affiche le message
}
} }
async function createPlanting() { async function createPlanting() {
if (editId.value) { if (submitting.value) return
await store.update(editId.value, { ...cForm }) submitting.value = true
} else { try {
await store.create({ ...cForm }) const payload = { ...cForm, cell_ids: cForm.cell_ids, cell_id: cForm.cell_ids[0] ?? undefined }
if (editId.value) {
await store.update(editId.value, payload)
toast.success('Plantation modifiée')
} else {
await store.create(payload)
toast.success('Plantation créée')
}
closeCreate()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
} }
closeCreate()
Object.assign(cForm, {
garden_id: 0, variety_id: 0, quantite: 1, date_plantation: '', statut: 'prevu',
boutique_nom: '', boutique_url: '', tarif_achat: undefined, date_achat: '',
notes: '',
})
} }
onMounted(() => { onMounted(async () => {
store.fetchAll() try {
gardensStore.fetchAll() await Promise.all([
plantsStore.fetchAll() store.fetchAll(),
loadTemplates() gardensStore.fetchAll(),
plantsStore.fetchAll(),
loadTemplates(),
])
} catch {
toast.error('Erreur lors du chargement des données')
}
}) })
</script> </script>

View File

@@ -1,321 +1,306 @@
<template> <template>
<div class="p-4 max-w-6xl mx-auto"> <div class="p-4 max-w-[1800px] mx-auto space-y-6">
<div class="flex items-center justify-between mb-6"> <!-- En-tête -->
<h1 class="text-2xl font-bold text-green">🌱 Plantes</h1> <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<button @click="showForm = true" class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">+ Ajouter</button> <div class="flex items-center gap-4">
<h1 class="text-3xl font-bold text-yellow tracking-tight">Bibliothèque des Plantes</h1>
<div class="flex gap-2">
<button v-for="cat in categories" :key="cat.val"
@click="selectedCat = cat.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
selectedCat === cat.val ? 'bg-yellow text-bg border-yellow' : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ cat.label }}
</button>
</div>
</div>
<div class="flex items-center gap-3">
<div class="relative min-w-[300px]">
<span class="absolute left-3 top-1/2 -translate-y-1/2 opacity-40">🔍</span>
<input
v-model="searchQuery"
placeholder="Rechercher une plante, variété..."
class="w-full bg-bg-hard border border-bg-soft rounded-full pl-9 pr-4 py-2 text-text text-sm focus:border-yellow transition-all outline-none shadow-inner"
/>
</div>
<button @click="showForm = true" 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> Ajouter une plante
</button>
</div>
</div> </div>
<!-- Filtres catégorie --> <!-- Grille de 5 colonnes -->
<div class="flex gap-2 mb-4 flex-wrap"> <div v-if="plantsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<button v-for="cat in categories" :key="cat.val" <div v-for="i in 10" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
@click="selectedCat = cat.val"
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors',
selectedCat === cat.val ? 'bg-green text-bg' : 'bg-bg-soft text-text-muted hover:text-text']">
{{ cat.label }}
</button>
</div> </div>
<!-- Liste --> <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<div v-if="plantsStore.loading" class="text-text-muted text-sm">Chargement...</div>
<div v-else-if="!filteredPlants.length" class="text-text-muted text-sm py-4">Aucune plante.</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
<div v-for="p in filteredPlants" :key="p.id" <div v-for="p in filteredPlants" :key="p.id"
class="bg-bg-soft rounded-lg border border-bg-hard overflow-hidden"> class="card-jardin !p-0 group overflow-hidden flex flex-col hover:border-yellow/40 transition-all border-l-[6px] relative min-h-[160px] cursor-pointer"
<!-- En-tête cliquable --> :style="{ borderLeftColor: getCatColor(p.categorie || '') }"
<div class="p-4 flex items-start justify-between gap-4 cursor-pointer" @click="openDetails(p)">
@click="toggleDetail(p.id!)">
<div class="flex-1 min-w-0"> <!-- Badge catégorie en haut à gauche -->
<div class="flex items-center gap-2 mb-1 flex-wrap"> <div class="absolute top-2 left-2">
<span class="text-text font-semibold">{{ p.nom_commun }}</span> <span :class="['text-[7px] font-black uppercase tracking-[0.2em] px-2 py-0.5 rounded bg-bg/60 backdrop-blur-sm', catTextClass(p.categorie || '')]">
<span v-if="p.variete" class="text-text-muted text-xs"> {{ p.variete }}</span> {{ p.categorie }}
<span v-if="p.categorie" :class="['text-xs px-2 py-0.5 rounded-full font-medium', catClass(p.categorie)]">{{ catLabel(p.categorie) }}</span> </span>
</div>
<div class="text-text-muted text-xs flex gap-3 flex-wrap">
<span v-if="p.famille">🌿 {{ p.famille }}</span>
<span v-if="p.espacement_cm"> {{ p.espacement_cm }}cm</span>
<span v-if="p.besoin_eau">💧 {{ p.besoin_eau }}</span>
<span v-if="p.plantation_mois">🌱 Plantation: mois {{ p.plantation_mois }}</span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-text-muted text-xs">{{ openId === p.id ? '▲' : '▼' }}</span>
<button @click.stop="startEdit(p)" class="text-yellow text-xs hover:underline">Édit.</button>
<button @click.stop="removePlant(p.id!)" class="text-red text-xs hover:underline">Suppr.</button>
</div>
</div> </div>
<!-- Panneau détail --> <div class="p-5 flex-1 flex flex-col justify-center">
<div v-if="openId === p.id" class="border-t border-bg-hard px-4 pb-4 pt-3"> <h2 class="text-text font-bold text-2xl leading-tight group-hover:text-yellow transition-colors">{{ p.nom_commun }}</h2>
<!-- Notes --> <p v-if="p.variete" class="text-text-muted text-[10px] font-black uppercase tracking-widest mt-1 opacity-60">{{ p.variete }}</p>
<p v-if="p.notes" class="text-text-muted text-sm mb-3 italic">{{ p.notes }}</p>
<!-- Galerie photos --> <div class="mt-4 flex flex-wrap gap-2">
<div class="mb-2 flex items-center justify-between"> <div v-if="p.plantation_mois" class="flex items-center gap-2 bg-bg/40 px-2 py-1 rounded border border-bg-soft">
<span class="text-text-muted text-xs font-medium uppercase tracking-wide">Photos</span> <span class="text-[10px]">📅</span>
<button @click="openUpload(p)" class="text-green text-xs hover:underline">+ Ajouter une photo</button> <span class="text-[10px] font-black text-text-muted">P: {{ p.plantation_mois }}</span>
</div> </div>
<div v-if="p.besoin_eau" class="flex items-center gap-2 bg-bg/40 px-2 py-1 rounded border border-bg-soft">
<div v-if="loadingPhotos" class="text-text-muted text-xs">Chargement...</div> <span class="text-[10px] text-blue">💧</span>
<div v-else-if="!plantPhotos.length" class="text-text-muted text-xs mb-3">Aucune photo pour cette plante.</div> <span class="text-[10px] font-black text-text-muted">{{ p.besoin_eau }}</span>
<div v-else class="grid grid-cols-4 gap-2 mb-3">
<div v-for="m in plantPhotos" :key="m.id"
class="aspect-square rounded overflow-hidden bg-bg-hard relative group cursor-pointer"
@click="lightbox = m">
<img :src="m.thumbnail_url || m.url" class="w-full h-full object-cover" />
<div v-if="m.identified_common"
class="absolute bottom-0 left-0 right-0 bg-black/70 text-xs text-green px-1 py-0.5 truncate">
{{ m.identified_common }}
</div>
<button @click.stop="deletePhoto(m)" class="hidden group-hover:flex absolute top-1 right-1 bg-red/80 text-white text-xs rounded px-1"></button>
</div> </div>
</div> </div>
<!-- Lier une photo existante de la bibliothèque -->
<button @click="openLinkPhoto(p)" class="text-blue text-xs hover:underline">
🔗 Lier une photo existante de la bibliothèque
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal formulaire création / édition --> <!-- Modale de Détails (Popup) -->
<div v-if="showForm || editPlant" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm"> <div v-if="detailPlant" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="detailPlant = null">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-4xl border border-bg-soft max-h-[90vh] overflow-y-auto"> <div class="bg-bg-hard rounded-3xl w-full max-w-2xl border border-bg-soft shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<h2 class="text-text font-bold text-lg mb-4">{{ editPlant ? 'Modifier la plante' : 'Nouvelle plante' }}</h2> <!-- Header de la modale -->
<form @submit.prevent="submitPlant" class="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div class="p-6 border-b border-bg-soft flex justify-between items-start" :style="{ borderLeft: `8px solid ${getCatColor(detailPlant.categorie || '')}` }">
<div> <div>
<label class="text-text-muted text-xs block mb-1">Nom commun *</label> <span :class="['text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded mb-2 inline-block bg-bg/50', catTextClass(detailPlant.categorie || '')]">
<input v-model="form.nom_commun" placeholder="Ex: Tomate" required {{ detailPlant.categorie }}
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" /> </span>
<p class="text-text-muted text-[11px] mt-1">Nom utilisé au jardin pour identifier rapidement la plante.</p> <h2 class="text-text font-black text-4xl leading-none">{{ detailPlant.nom_commun }}</h2>
<p v-if="detailPlant.variete" class="text-yellow font-bold uppercase tracking-widest text-sm mt-1">{{ detailPlant.variete }}</p>
</div> </div>
<div> <button @click="detailPlant = null" class="text-text-muted hover:text-red transition-colors text-2xl"></button>
<label class="text-text-muted text-xs block mb-1">Nom botanique</label> </div>
<input v-model="form.nom_botanique" placeholder="Ex: Solanum lycopersicum"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" /> <!-- Corps de la modale -->
<p class="text-text-muted text-[11px] mt-1">Nom scientifique utile pour éviter les ambiguïtés.</p> <div class="p-6 overflow-y-auto space-y-6">
<!-- Caractéristiques -->
<div class="grid grid-cols-3 gap-4">
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Besoin en eau</span>
<div class="flex items-center gap-2 text-blue">
<span>💧</span>
<span class="font-bold capitalize">{{ detailPlant.besoin_eau }}</span>
</div>
</div>
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Exposition</span>
<div class="flex items-center gap-2 text-yellow">
<span></span>
<span class="font-bold capitalize">{{ detailPlant.besoin_soleil }}</span>
</div>
</div>
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Plantation</span>
<div class="flex items-center gap-2 text-green">
<span>📅</span>
<span class="font-bold">Mois: {{ detailPlant.plantation_mois }}</span>
</div>
</div>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Variété</label> <!-- Notes -->
<input v-model="form.variete" placeholder="Ex: Andine Cornue" <div v-if="detailPlant.notes" class="space-y-2">
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" /> <h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Conseils & Notes</h3>
<p class="text-text-muted text-[11px] mt-1">Cultivar précis (optionnel).</p> <div class="bg-bg/40 p-4 rounded-2xl border-l-4 border-yellow/30 text-text/90 leading-relaxed italic text-sm whitespace-pre-line">
{{ detailPlant.notes }}
</div>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Famille botanique</label> <!-- Galerie Photos -->
<input v-model="form.famille" placeholder="Ex: Solanacées" <div class="space-y-3">
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" /> <div class="flex justify-between items-center">
<p class="text-text-muted text-[11px] mt-1">Permet d'organiser la rotation des cultures.</p> <h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Photos & Médias</h3>
<button @click="openUpload(detailPlant)" class="text-[10px] font-black text-yellow hover:underline uppercase">+ Ajouter</button>
</div>
<div v-if="loadingPhotos" class="grid grid-cols-4 gap-2 animate-pulse">
<div v-for="i in 4" :key="i" class="aspect-square bg-bg-soft rounded-lg"></div>
</div>
<div v-else-if="plantPhotos.length" class="grid grid-cols-4 gap-2">
<div v-for="m in plantPhotos" :key="m.id"
class="aspect-square rounded-lg overflow-hidden bg-bg relative group cursor-pointer border border-bg-soft"
@click="lightbox = m">
<img :src="m.thumbnail_url || m.url" class="w-full h-full object-cover group-hover:scale-110 transition-transform" />
<button @click.stop="deletePhoto(m)" class="absolute top-1 right-1 w-6 h-6 bg-red/80 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"></button>
</div>
</div>
<div v-else class="text-center py-8 bg-bg/20 rounded-2xl border border-dashed border-bg-soft opacity-40">
<span class="text-xs italic">Aucune photo pour le moment</span>
</div>
</div> </div>
<div> </div>
<label class="text-text-muted text-xs block mb-1">Catégorie</label>
<select v-model="form.categorie" <!-- Footer de la modale -->
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green"> <div class="p-4 bg-bg-hard border-t border-bg-soft flex gap-3">
<option value="">Catégorie</option> <button @click="startEdit(detailPlant)" class="btn-primary !bg-yellow !text-bg flex-1 py-3 font-black uppercase text-xs tracking-widest">Modifier la fiche</button>
<option value="potager">Potager</option> <button @click="removePlant(detailPlant.id!)" class="btn-outline !border-red/20 !text-red hover:bg-red/10 px-6 py-3 font-black uppercase text-xs tracking-widest">Supprimer</button>
<option value="fleur">Fleur</option> </div>
<option value="arbre">Arbre</option> </div>
<option value="arbuste">Arbuste</option> </div>
<option value="adventice">Adventice (mauvaise herbe)</option>
</select> <!-- Modale Formulaire (Ajout/Edition) -->
<p class="text-text-muted text-[11px] mt-1">Classe principale pour filtrer la bibliothèque de plantes.</p> <div v-if="showForm || editPlant" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-3xl p-8 w-full max-w-4xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-8 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-2xl uppercase tracking-tighter">{{ editPlant ? 'Modifier la plante' : 'Nouvelle plante' }}</h2>
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-2xl"></button>
</div>
<form @submit.prevent="submitPlant" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Champs de formulaire identiques -->
<div class="space-y-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom commun *</label>
<input v-model="form.nom_commun" 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" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Variété</label>
<input v-model="form.variete" 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="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Catégorie</label>
<select v-model="form.categorie" 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="c in categories.slice(1)" :key="c.val" :value="c.val">{{ c.label.split(' ')[1] }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Famille</label>
<input v-model="form.famille" 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>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Type de plante</label> <div class="space-y-4">
<select v-model="form.type_plante" <div class="grid grid-cols-2 gap-4">
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green"> <div>
<option value="">Type</option> <label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Eau</label>
<option value="legume">Légume</option> <select v-model="form.besoin_eau" 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="fruit">Fruit</option> <option value="faible">Faible</option>
<option value="aromatique">Aromatique</option> <option value="moyen">Moyen</option>
<option value="fleur">Fleur</option> <option value="élevé">Élevé</option>
<option value="adventice">Adventice</option> </select>
</select> </div>
<p class="text-text-muted text-[11px] mt-1">Type d'usage de la plante (récolte, ornement, etc.).</p> <div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Soleil</label>
<select v-model="form.besoin_soleil" 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="ombre">Ombre</option>
<option value="mi-ombre">Mi-ombre</option>
<option value="plein soleil">Plein soleil</option>
</select>
</div>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Mois Plantation (ex: 3,4,5)</label>
<input v-model="form.plantation_mois" 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">Notes & Conseils</label>
<textarea v-model="form.notes" rows="1" @input="autoResize" 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 overflow-hidden" />
</div>
</div> </div>
<div>
<label class="text-text-muted text-xs block mb-1">Besoin en eau</label> <div class="md:col-span-2 flex justify-between items-center pt-6 border-t border-bg-soft mt-4">
<select v-model="form.besoin_eau" <button type="button" @click="closeForm" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green"> <button type="submit" class="btn-primary px-12 py-4 text-base shadow-xl !bg-yellow !text-bg">
<option value="">Besoin en eau</option> {{ editPlant ? 'Sauvegarder' : 'Enregistrer' }}
<option value="faible">Faible</option>
<option value="moyen">Moyen</option>
<option value="élevé">Élevé</option>
</select>
<p class="text-text-muted text-[11px] mt-1">Aide à planifier l'arrosage.</p>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Ensoleillement</label>
<select v-model="form.besoin_soleil"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green">
<option value="">Ensoleillement</option>
<option value="ombre">Ombre</option>
<option value="mi-ombre">Mi-ombre</option>
<option value="plein soleil">Plein soleil</option>
</select>
<p class="text-text-muted text-[11px] mt-1">Exposition lumineuse idéale.</p>
</div>
<div class="lg:col-span-2 flex gap-2">
<input v-model.number="form.espacement_cm" type="number" placeholder="Espacement (cm)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm flex-1 outline-none focus:border-green" />
<input v-model.number="form.temp_min_c" type="number" placeholder="T° min (°C)"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm flex-1 outline-none focus:border-green" />
</div>
<p class="lg:col-span-2 text-text-muted text-[11px] -mt-2">Espacement recommandé en cm et température minimale supportée (en °C).</p>
<div>
<label class="text-text-muted text-xs block mb-1">Mois de plantation</label>
<input v-model="form.plantation_mois" placeholder="Ex: 3,4,5"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<p class="text-text-muted text-[11px] mt-1">Liste des mois conseillés, séparés par des virgules.</p>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Mois de récolte</label>
<input v-model="form.recolte_mois" placeholder="Ex: 7,8,9"
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green" />
<p class="text-text-muted text-[11px] mt-1">Période habituelle de récolte.</p>
</div>
<div class="lg:col-span-2">
<label class="text-text-muted text-xs block mb-1">Notes</label>
<textarea v-model="form.notes" placeholder="Observations, maladies, astuces..."
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-green resize-none h-20" />
<p class="text-text-muted text-[11px] mt-1">Commentaires libres visibles dans le détail de la plante.</p>
</div>
<div class="lg:col-span-2 flex gap-2 justify-end">
<button type="button" @click="closeForm"
class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button type="submit"
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
{{ editPlant ? 'Enregistrer' : 'Créer' }}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Modal upload photo pour une plante --> <!-- Lightbox Photo -->
<div v-if="uploadTarget" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" @click.self="uploadTarget = null"> <div v-if="lightbox" class="fixed inset-0 bg-black/95 z-[100] flex items-center justify-center p-4" @click="lightbox = null">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-sm border border-bg-soft"> <img :src="lightbox.url" class="max-w-full max-h-full object-contain rounded-lg shadow-2xl animate-fade-in" />
<h3 class="text-text font-bold mb-4">Photo pour "{{ formatPlantLabel(uploadTarget) }}"</h3> <button class="absolute top-6 right-6 text-white text-4xl hover:text-yellow"></button>
<label class="block border-2 border-dashed border-bg-soft rounded-lg p-6 text-center cursor-pointer hover:border-green transition-colors">
<input type="file" accept="image/*" class="hidden" @change="uploadPhoto" />
<div class="text-text-muted text-sm">📷 Choisir une image</div>
</label>
<button @click="uploadTarget = null" class="mt-3 w-full text-text-muted hover:text-text text-sm">Annuler</button>
</div>
</div> </div>
<!-- Modal lier photo existante --> <!-- Upload Photo Trigger (Invisible) -->
<div v-if="linkTarget" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" @click.self="linkTarget = null"> <input type="file" ref="fileInput" accept="image/*" class="hidden" @change="handleFileUpload" />
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-2xl border border-bg-soft max-h-[80vh] flex flex-col">
<h3 class="text-text font-bold mb-3">Lier une photo à "{{ formatPlantLabel(linkTarget) }}"</h3>
<p class="text-text-muted text-xs mb-3">Sélectionne une photo de la bibliothèque (non liée à une plante)</p>
<div v-if="!unlinkPhotos.length" class="text-text-muted text-sm py-4 text-center">Aucune photo disponible.</div>
<div v-else class="grid grid-cols-4 gap-2 overflow-y-auto flex-1">
<div v-for="m in unlinkPhotos" :key="m.id"
class="aspect-square rounded overflow-hidden bg-bg-hard relative cursor-pointer group border-2 transition-colors"
:class="selectedLinkPhoto === m.id ? 'border-green' : 'border-transparent'"
@click="selectedLinkPhoto = m.id">
<img :src="m.thumbnail_url || m.url" class="w-full h-full object-cover" />
<div v-if="m.identified_common" class="absolute bottom-0 left-0 right-0 bg-black/70 text-xs text-green px-1 py-0.5 truncate">{{ m.identified_common }}</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<button @click="linkTarget = null" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
<button @click="confirmLink" :disabled="!selectedLinkPhoto"
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-40">
Lier la photo
</button>
</div>
</div>
</div>
<!-- Lightbox -->
<div v-if="lightbox" class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4" @click.self="lightbox = null">
<div class="max-w-lg w-full">
<img :src="lightbox.url" class="w-full rounded-xl" />
<div v-if="lightbox.identified_species" class="text-center mt-3 text-text-muted text-sm">
<div class="text-green font-semibold text-base">{{ lightbox.identified_common }}</div>
<div class="italic">{{ lightbox.identified_species }}</div>
<div class="text-xs mt-1">Confiance : {{ Math.round((lightbox.identified_confidence || 0) * 100) }}% — via {{ lightbox.identified_source }}</div>
</div>
<button class="mt-4 w-full text-text-muted hover:text-text text-sm" @click="lightbox = null">Fermer</button>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import axios from 'axios' import axios from 'axios'
import { usePlantsStore } from '@/stores/plants' import { usePlantsStore } from '@/stores/plants'
import type { Plant } from '@/api/plants' import type { Plant } from '@/api/plants'
import { formatPlantLabel } from '@/utils/plants' import { formatPlantLabel } from '@/utils/plants'
import { useToast } from '@/composables/useToast'
const plantsStore = usePlantsStore() const plantsStore = usePlantsStore()
const toast = useToast()
const showForm = ref(false) const showForm = ref(false)
const submitting = ref(false)
const editPlant = ref<Plant | null>(null) const editPlant = ref<Plant | null>(null)
const detailPlant = ref<Plant | null>(null)
const selectedCat = ref('') const selectedCat = ref('')
const openId = ref<number | null>(null) const searchQuery = ref('')
const plantPhotos = ref<Media[]>([]) const plantPhotos = ref<Media[]>([])
const loadingPhotos = ref(false) const loadingPhotos = ref(false)
const uploadTarget = ref<Plant | null>(null)
const linkTarget = ref<Plant | null>(null)
const unlinkPhotos = ref<Media[]>([])
const selectedLinkPhoto = ref<number | null>(null)
const lightbox = ref<Media | null>(null) const lightbox = ref<Media | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const uploadTarget = ref<Plant | null>(null)
interface Media { interface Media {
id: number; entity_type: string; entity_id: number id: number; entity_type: string; entity_id: number
url: string; thumbnail_url?: string; titre?: string url: string; thumbnail_url?: string; titre?: string
identified_species?: string; identified_common?: string
identified_confidence?: number; identified_source?: string
} }
const categories = [ const categories = [
{ val: '', label: 'Toutes' }, { val: '', label: 'TOUTES' },
{ val: 'potager', label: '🥕 Potager' }, { val: 'potager', label: '🥕 POTAGER' },
{ val: 'fleur', label: '🌸 Fleur' }, { val: 'fleur', label: '🌸 FLEUR' },
{ val: 'arbre', label: '🌳 Arbre' }, { val: 'arbre', label: '🌳 ARBRE' },
{ val: 'arbuste', label: '🌿 Arbuste' }, { val: 'arbuste', label: '🌿 ARBUSTE' },
{ val: 'adventice', label: '🌾 Adventices' }, { val: 'adventice', label: '🌾 ADVENTICES' },
] ]
const form = reactive({ const form = reactive({
nom_commun: '', nom_botanique: '', variete: '', famille: '', nom_commun: '', variete: '', famille: '',
categorie: '', type_plante: '', besoin_eau: '', besoin_soleil: '', categorie: 'potager', besoin_eau: 'moyen', besoin_soleil: 'plein soleil',
espacement_cm: undefined as number | undefined, plantation_mois: '', notes: '',
temp_min_c: undefined as number | undefined,
plantation_mois: '', recolte_mois: '', notes: '',
}) })
const filteredPlants = computed(() => { const filteredPlants = computed(() => {
const source = selectedCat.value let source = plantsStore.plants
? plantsStore.plants.filter(p => p.categorie === selectedCat.value) if (selectedCat.value) source = source.filter(p => p.categorie === selectedCat.value)
: plantsStore.plants if (searchQuery.value) {
return [...source].sort((a, b) => { const q = searchQuery.value.toLowerCase()
const byName = (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr', { sensitivity: 'base' }) source = source.filter(p =>
if (byName !== 0) return byName p.nom_commun?.toLowerCase().includes(q) ||
return (a.variete || '').localeCompare(b.variete || '', 'fr', { sensitivity: 'base' }) p.variete?.toLowerCase().includes(q)
}) )
}
return [...source].sort((a, b) => (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr'))
}) })
const catClass = (cat: string) => ({ function getCatColor(cat: string) {
potager: 'bg-green/20 text-green', return ({
fleur: 'bg-orange/20 text-orange', potager: '#b8bb26', fleur: '#fabd2f', arbre: '#83a598',
arbre: 'bg-blue/20 text-blue', arbuste: '#d3869b', adventice: '#fb4934',
arbuste: 'bg-yellow/20 text-yellow', } as any)[cat] || '#928374'
adventice: 'bg-red/20 text-red', }
}[cat] || 'bg-bg text-text-muted')
const catLabel = (cat: string) => ({ function catTextClass(cat: string) {
potager: '🥕 Potager', fleur: '🌸 Fleur', arbre: '🌳 Arbre', return ({
arbuste: '🌿 Arbuste', adventice: '🌾 Adventice', potager: 'text-green', fleur: 'text-yellow', arbre: 'text-blue',
}[cat] || cat) arbuste: 'text-purple', adventice: 'text-red',
} as any)[cat] || 'text-text-muted'
}
async function toggleDetail(id: number) { async function openDetails(p: Plant) {
if (openId.value === id) { openId.value = null; return } detailPlant.value = p
openId.value = id await fetchPhotos(p.id!)
await fetchPhotos(id)
} }
async function fetchPhotos(plantId: number) { async function fetchPhotos(plantId: number) {
@@ -331,86 +316,102 @@ async function fetchPhotos(plantId: number) {
} }
function startEdit(p: Plant) { function startEdit(p: Plant) {
detailPlant.value = null
editPlant.value = p editPlant.value = p
Object.assign(form, { Object.assign(form, {
nom_commun: p.nom_commun || '', nom_botanique: (p as any).nom_botanique || '', nom_commun: p.nom_commun || '', variete: p.variete || '', famille: p.famille || '',
variete: p.variete || '', famille: p.famille || '', categorie: p.categorie || 'potager', besoin_eau: p.besoin_eau || 'moyen', besoin_soleil: p.besoin_soleil || 'plein soleil',
categorie: p.categorie || '', type_plante: p.type_plante || '', plantation_mois: p.plantation_mois || '', notes: p.notes || '',
besoin_eau: p.besoin_eau || '', besoin_soleil: p.besoin_soleil || '',
espacement_cm: p.espacement_cm, temp_min_c: p.temp_min_c,
plantation_mois: p.plantation_mois || '', recolte_mois: p.recolte_mois || '',
notes: p.notes || '',
}) })
showForm.value = true
} }
function closeForm() { function closeForm() {
showForm.value = false showForm.value = false
editPlant.value = null editPlant.value = null
Object.assign(form, {
nom_commun: '', nom_botanique: '', variete: '', famille: '', categorie: '',
type_plante: '', besoin_eau: '', besoin_soleil: '',
espacement_cm: undefined, temp_min_c: undefined,
plantation_mois: '', recolte_mois: '', notes: '',
})
} }
async function submitPlant() { async function submitPlant() {
if (editPlant.value) { if (submitting.value) return
await axios.put(`/api/plants/${editPlant.value.id}`, { ...form }) submitting.value = true
await plantsStore.fetchAll() try {
} else { if (editPlant.value) {
await plantsStore.create({ ...form }) await axios.put(`/api/plants/${editPlant.value.id}`, { ...form })
await plantsStore.fetchAll()
toast.success('Plante modifiée')
} else {
await plantsStore.create({ ...form })
toast.success('Plante créée')
}
closeForm()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
} }
closeForm()
} }
async function removePlant(id: number) { async function removePlant(id: number) {
if (confirm('Supprimer cette plante ?')) { if (!confirm('Supprimer définitivement cette plante ?')) return
try {
await plantsStore.remove(id) await plantsStore.remove(id)
if (openId.value === id) openId.value = null detailPlant.value = null
toast.success('Plante supprimée')
} catch {
// L'intercepteur Axios affiche le message
} }
} }
function openUpload(p: Plant) { uploadTarget.value = p } function openUpload(p: Plant) {
uploadTarget.value = p
fileInput.value?.click()
}
async function uploadPhoto(e: Event) { async function handleFileUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !uploadTarget.value) return if (!file || !uploadTarget.value) return
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
const { data: uploaded } = await axios.post('/api/upload', fd)
await axios.post('/api/media', { try {
entity_type: 'plante', entity_id: uploadTarget.value.id, const { data: uploaded } = await axios.post('/api/upload', fd)
url: uploaded.url, thumbnail_url: uploaded.thumbnail_url, await axios.post('/api/media', {
}) entity_type: 'plante', entity_id: uploadTarget.value.id,
uploadTarget.value = null url: uploaded.url, thumbnail_url: uploaded.thumbnail_url,
if (openId.value) await fetchPhotos(openId.value) })
await fetchPhotos(uploadTarget.value.id!)
toast.success('Photo ajoutée')
} catch {
// L'intercepteur Axios affiche le message
} finally {
uploadTarget.value = null
if (fileInput.value) fileInput.value.value = ''
}
} }
async function deletePhoto(m: Media) { async function deletePhoto(m: Media) {
if (!confirm('Supprimer cette photo ?')) return if (!confirm('Supprimer cette photo ?')) return
await axios.delete(`/api/media/${m.id}`) try {
if (openId.value) await fetchPhotos(openId.value) await axios.delete(`/api/media/${m.id}`)
if (detailPlant.value) await fetchPhotos(detailPlant.value.id!)
toast.success('Photo supprimée')
} catch {
// L'intercepteur Axios affiche le message
}
} }
async function openLinkPhoto(p: Plant) { function autoResize(event: Event) {
linkTarget.value = p const el = event.target as HTMLTextAreaElement
selectedLinkPhoto.value = null el.style.height = 'auto'
const { data } = await axios.get<Media[]>('/api/media/all') el.style.height = el.scrollHeight + 'px'
// Photos non liées à une plante (bibliothèque ou autres)
unlinkPhotos.value = data.filter(m => m.entity_type !== 'plante')
} }
async function confirmLink() { onMounted(async () => {
if (!selectedLinkPhoto.value || !linkTarget.value) return try {
await axios.patch(`/api/media/${selectedLinkPhoto.value}`, { await plantsStore.fetchAll()
entity_type: 'plante', entity_id: linkTarget.value.id, } catch {
}) toast.error('Impossible de charger les plantes')
const pid = linkTarget.value.id }
linkTarget.value = null })
selectedLinkPhoto.value = null
if (openId.value === pid) await fetchPhotos(pid)
}
onMounted(() => plantsStore.fetchAll())
</script> </script>

View File

@@ -1,121 +1,181 @@
<template> <template>
<div class="p-4 max-w-3xl mx-auto"> <div class="p-4 max-w-[1800px] mx-auto space-y-8">
<h1 class="text-2xl font-bold text-green mb-4">Réglages</h1> <div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-green tracking-tight">Réglages Système</h1>
<p class="text-text-muted text-xs mt-1">Configurez l'interface, la maintenance et la sécurité de votre application.</p>
</div>
</div>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
<h2 class="text-text font-semibold mb-2">Interface</h2> <!-- Section Interface -->
<p class="text-text-muted text-sm mb-4">Ajustez les tailles d'affichage. Les changements sont appliqués instantanément.</p> <section class="card-jardin flex flex-col h-full border-green/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<div class="grid grid-cols-1 gap-4"> <span class="text-2xl">🎨</span>
<div v-for="s in uiSizeSettings" :key="s.key" class="flex items-center gap-3"> <div>
<label class="text-sm text-text w-44 shrink-0">{{ s.label }}</label> <h2 class="text-text font-bold uppercase tracking-widest text-xs">Interface Graphique</h2>
<input <p class="text-[10px] text-text-muted font-bold">Ajustez les échelles visuelles.</p>
type="range" </div>
:min="s.min" :max="s.max" :step="s.step"
v-model.number="uiSizes[s.key]"
class="flex-1 accent-green"
@input="applyUiSizes"
/>
<span class="text-text-muted text-xs w-12 text-right">{{ uiSizes[s.key] }}{{ s.unit }}</span>
</div> </div>
</div>
<div class="mt-4 flex items-center gap-2"> <div class="flex-1 space-y-6">
<button <div v-for="s in uiSizeSettings" :key="s.key" class="space-y-2">
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90" <div class="flex justify-between items-center">
:disabled="savingUi" <label class="text-[10px] font-black uppercase tracking-widest text-text-muted">{{ s.label }}</label>
@click="saveUiSettings" <span class="text-xs font-mono text-green">{{ uiSizes[s.key] }}{{ s.unit }}</span>
>{{ savingUi ? 'Enregistrement...' : 'Enregistrer' }}</button> </div>
<button <input
class="text-text-muted text-xs hover:text-text px-2" type="range"
@click="resetUiSettings" :min="s.min" :max="s.max" :step="s.step"
>Réinitialiser</button> v-model.number="uiSizes[s.key]"
<span v-if="uiSavedMsg" class="text-xs text-aqua">{{ uiSavedMsg }}</span> class="w-full h-1.5 bg-bg-hard rounded-lg appearance-none cursor-pointer accent-green"
</div> @input="applyUiSizes"
</section> />
</div>
</div>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4"> <div class="mt-8 pt-4 border-t border-bg-hard flex items-center justify-between">
<h2 class="text-text font-semibold mb-2">Général</h2> <button
<p class="text-text-muted text-sm mb-3">Options globales de l'application.</p> class="text-[10px] font-black uppercase tracking-widest text-text-muted hover:text-text transition-colors"
@click="resetUiSettings"
>Réinitialiser</button>
<div class="flex items-center gap-3">
<span v-if="uiSavedMsg" class="text-[10px] font-bold text-aqua animate-pulse">{{ uiSavedMsg }}</span>
<button
class="btn-primary !py-2 !px-6 text-xs"
:disabled="savingUi"
@click="saveUiSettings"
>{{ savingUi ? '...' : 'Enregistrer' }}</button>
</div>
</div>
</section>
<label class="inline-flex items-center gap-2 text-sm text-text"> <!-- Section Général / Debug -->
<input v-model="debugMode" type="checkbox" class="accent-green" /> <section class="card-jardin flex flex-col h-full border-yellow/20">
Activer le mode debug (affichage CPU / RAM / disque en header) <div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
</label> <span class="text-2xl">⚙️</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Général & Debug</h2>
<p class="text-[10px] text-text-muted font-bold">Options globales du système.</p>
</div>
</div>
<div class="mt-3 flex items-center gap-2"> <div class="flex-1 space-y-6">
<button <label class="flex items-start gap-4 cursor-pointer group bg-bg-hard/30 p-4 rounded-2xl border border-bg-soft/50 hover:border-yellow/30 transition-all">
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90" <div class="relative mt-1">
:disabled="saving" <input v-model="debugMode" type="checkbox" class="sr-only peer" />
@click="saveSettings" <div class="w-10 h-5 bg-bg-hard rounded-full peer peer-checked:bg-yellow transition-colors"></div>
> <div class="absolute left-1 top-1 w-3 h-3 bg-text-muted peer-checked:bg-bg peer-checked:translate-x-5 rounded-full transition-all"></div>
{{ saving ? 'Enregistrement...' : 'Enregistrer' }} </div>
</button> <div class="flex-1">
<span v-if="savedMsg" class="text-xs text-aqua">{{ savedMsg }}</span> <div class="text-sm font-bold text-text group-hover:text-yellow transition-colors">Mode Debug Interactif</div>
</div> <p class="text-[10px] text-text-muted mt-1 leading-relaxed italic">Affiche les statistiques vitales (CPU, RAM, Disque) dans la barre de navigation supérieure.</p>
</section> </div>
</label>
</div>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4"> <div class="mt-8 pt-4 border-t border-bg-hard flex items-center justify-end gap-3">
<h2 class="text-text font-semibold mb-2">Maintenance météo</h2> <span v-if="savedMsg" class="text-[10px] font-bold text-aqua">{{ savedMsg }}</span>
<p class="text-text-muted text-sm mb-3">Déclenche un rafraîchissement immédiat des jobs météo backend.</p> <button
<button class="btn-primary !bg-yellow !text-bg !py-2 !px-6 text-xs"
class="bg-blue text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90" :disabled="saving"
:disabled="refreshingMeteo" @click="saveSettings"
@click="refreshMeteo" >
> {{ saving ? '...' : 'Appliquer' }}
{{ refreshingMeteo ? 'Rafraîchissement...' : 'Rafraîchir maintenant' }} </button>
</button> </div>
</section> </section>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4"> <!-- Section Maintenance -->
<h2 class="text-text font-semibold mb-2">Test API backend</h2> <section class="card-jardin flex flex-col h-full border-blue/20">
<p class="text-text-muted text-sm mb-2"> <div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
Ouvre la documentation interactive de l'API et un test rapide de santé. <span class="text-2xl">🌦️</span>
</p> <div>
<p class="text-text-muted text-xs mb-3">Base API détectée: <span class="text-text">{{ apiBaseUrl }}</span></p> <h2 class="text-text font-bold uppercase tracking-widest text-xs">Maintenance Météo</h2>
<div class="flex flex-wrap items-center gap-2"> <p class="text-[10px] text-text-muted font-bold">Synchronisation des données externes.</p>
<button </div>
class="bg-blue text-bg px-3 py-2 rounded-lg text-xs font-semibold hover:opacity-90" </div>
@click="openApiDocs"
>
Ouvrir Swagger (/docs)
</button>
<button
class="bg-aqua text-bg px-3 py-2 rounded-lg text-xs font-semibold hover:opacity-90"
@click="openApiRedoc"
>
Ouvrir ReDoc (/redoc)
</button>
<button
class="bg-bg border border-bg-hard text-text px-3 py-2 rounded-lg text-xs font-semibold hover:border-text-muted"
@click="openApiHealth"
>
Tester /api/health
</button>
</div>
</section>
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4"> <div class="flex-1">
<h2 class="text-text font-semibold mb-2">Sauvegarde des données</h2> <p class="text-xs text-text-muted leading-relaxed mb-6">
<p class="text-text-muted text-sm mb-3"> Force le rafraîchissement des prévisions Open-Meteo et des relevés de la station locale WeeWX. Les données sont normalement mises à jour toutes les heures automatiquement.
Exporte un ZIP téléchargeable contenant la base SQLite, les images/vidéos uploadées et les fichiers texte utiles. </p>
</p>
<div class="flex items-center gap-2"> <button
<button class="btn-outline w-full py-4 border-blue/20 text-blue hover:bg-blue/10 flex flex-col items-center gap-2"
class="bg-aqua text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90" :disabled="refreshingMeteo"
:disabled="downloadingBackup" @click="refreshMeteo"
@click="downloadBackup" >
> <span class="text-lg">{{ refreshingMeteo ? '🔄' : '' }}</span>
{{ downloadingBackup ? 'Préparation du ZIP...' : 'Télécharger la sauvegarde (.zip)' }} <span class="text-[10px] font-black uppercase tracking-widest">{{ refreshingMeteo ? 'Rafraîchissement en cours...' : 'Forcer la mise à jour' }}</span>
</button> </button>
<span v-if="backupMsg" class="text-xs text-aqua">{{ backupMsg }}</span> </div>
</div> </section>
</section>
<!-- Section Sauvegarde -->
<section class="card-jardin flex flex-col h-full border-aqua/20">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">📦</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Sauvegarde & Export</h2>
<p class="text-[10px] text-text-muted font-bold">Protégez vos données.</p>
</div>
</div>
<div class="flex-1 space-y-6">
<p class="text-xs text-text-muted leading-relaxed">
Génère une archive complète (.zip) incluant votre base de données SQLite et tous les médias (photos/vidéos) uploadés.
</p>
<button
class="btn-primary !bg-aqua !text-bg w-full py-4 flex flex-col items-center gap-2 shadow-lg hover:shadow-aqua/20"
:disabled="downloadingBackup"
@click="downloadBackup"
>
<span class="text-xl">💾</span>
<span class="text-[10px] font-black uppercase tracking-widest">{{ downloadingBackup ? 'Préparation...' : 'Télécharger le Pack Complet' }}</span>
</button>
<div v-if="backupMsg" class="text-[10px] text-center font-bold text-aqua animate-bounce">{{ backupMsg }}</div>
</div>
</section>
<!-- Section API Docs (Largeur double sur XL+) -->
<section class="card-jardin xl:col-span-2 flex flex-col border-bg-soft/50">
<div class="flex items-center gap-3 mb-6 border-b border-bg-hard pb-4">
<span class="text-2xl">🛠️</span>
<div>
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Outils Développeur & API</h2>
<p class="text-[10px] text-text-muted font-bold">Documentation technique et monitoring.</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button @click="openApiDocs" class="btn-outline flex flex-col items-center gap-2 py-6 border-bg-soft hover:bg-bg-hard transition-all">
<span class="text-xl">📖</span>
<span class="text-[10px] font-bold uppercase">Swagger UI</span>
</button>
<button @click="openApiRedoc" class="btn-outline flex flex-col items-center gap-2 py-6 border-bg-soft hover:bg-bg-hard transition-all">
<span class="text-xl">📄</span>
<span class="text-[10px] font-bold uppercase">ReDoc Docs</span>
</button>
<button @click="openApiHealth" class="btn-outline flex flex-col items-center gap-2 py-6 border-bg-soft hover:bg-bg-hard transition-all">
<span class="text-xl">💓</span>
<span class="text-[10px] font-bold uppercase">Santé API</span>
</button>
</div>
<div class="mt-6 p-3 bg-bg-hard/50 rounded-xl border border-bg-soft/30 flex items-center justify-between">
<span class="text-[9px] font-bold text-text-muted uppercase tracking-widest">Base URL API détectée</span>
<span class="text-xs font-mono text-aqua">{{ apiBaseUrl }}</span>
</div>
</section>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { settingsApi } from '@/api/settings' import { settingsApi } from '@/api/settings'
import { meteoApi } from '@/api/meteo' import { meteoApi } from '@/api/meteo'
import { UI_SIZE_DEFAULTS, applyUiSizesToRoot } from '@/utils/uiSizeDefaults' import { UI_SIZE_DEFAULTS, applyUiSizesToRoot } from '@/utils/uiSizeDefaults'
@@ -130,10 +190,12 @@ const apiBaseUrl = detectApiBaseUrl()
// --- UI Size settings --- // --- UI Size settings ---
const uiSizeSettings = [ const uiSizeSettings = [
{ key: 'ui_font_size', label: 'Taille texte', min: 12, max: 20, step: 1, unit: 'px' }, { key: 'ui_font_size', label: 'Corps de texte', min: 12, max: 24, step: 1, unit: 'px' },
{ key: 'ui_menu_font_size', label: 'Texte menu latéral', min: 11, max: 18, step: 1, unit: 'px' }, { key: 'ui_menu_font_size', label: 'Texte menu latéral', min: 11, max: 20, step: 1, unit: 'px' },
{ key: 'ui_menu_icon_size', label: 'Icônes menu', min: 14, max: 28, step: 1, unit: 'px' }, { key: 'ui_menu_icon_size', label: 'Icônes menu', min: 14, max: 32, step: 1, unit: 'px' },
{ key: 'ui_thumb_size', label: 'Miniatures images/vidéo', min: 60, max: 200, step: 4, unit: 'px' }, { key: 'ui_thumb_size', label: 'Miniatures médias', min: 60, max: 300, step: 4, unit: 'px' },
{ key: 'ui_weather_icon_size', label: 'Icônes Météo', min: 32, max: 128, step: 4, unit: 'px' },
{ key: 'ui_dashboard_icon_size', label: 'Icônes Dashboard', min: 16, max: 64, step: 2, unit: 'px' },
] ]
const uiSizes = ref<Record<string, number>>({ ...UI_SIZE_DEFAULTS }) const uiSizes = ref<Record<string, number>>({ ...UI_SIZE_DEFAULTS })
@@ -189,17 +251,9 @@ function openInNewTab(path: string) {
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
} }
function openApiDocs() { function openApiDocs() { openInNewTab('/docs') }
openInNewTab('/docs') function openApiRedoc() { openInNewTab('/redoc') }
} function openApiHealth() { openInNewTab('/api/health') }
function openApiRedoc() {
openInNewTab('/redoc')
}
function openApiHealth() {
openInNewTab('/api/health')
}
function toBool(value: unknown): boolean { function toBool(value: unknown): boolean {
if (typeof value === 'boolean') return value if (typeof value === 'boolean') return value
@@ -223,7 +277,7 @@ async function loadSettings() {
} }
applyUiSizes() applyUiSizes()
} catch { } catch {
// Laisse la valeur locale si l'API n'est pas disponible. // Laisse la valeur locale
} }
} }
@@ -233,7 +287,7 @@ async function saveSettings() {
try { try {
await settingsApi.update({ debug_mode: debugMode.value ? '1' : '0' }) await settingsApi.update({ debug_mode: debugMode.value ? '1' : '0' })
notifyDebugChanged(debugMode.value) notifyDebugChanged(debugMode.value)
savedMsg.value = 'Enregistré' savedMsg.value = 'Pris en compte'
window.setTimeout(() => { savedMsg.value = '' }, 1800) window.setTimeout(() => { savedMsg.value = '' }, 1800)
} finally { } finally {
saving.value = false saving.value = false
@@ -262,9 +316,9 @@ async function downloadBackup() {
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
backupMsg.value = 'Téléchargement lancé.' backupMsg.value = 'ZIP prêt !'
} catch { } catch {
backupMsg.value = 'Erreur lors de la sauvegarde.' backupMsg.value = 'Erreur export.'
} finally { } finally {
downloadingBackup.value = false downloadingBackup.value = false
window.setTimeout(() => { backupMsg.value = '' }, 2200) window.setTimeout(() => { backupMsg.value = '' }, 2200)

View File

@@ -1,51 +1,157 @@
<template> <template>
<div class="p-4 max-w-5xl mx-auto"> <div class="p-4 max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green"> Tâches</h1> <!-- En-tête -->
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90" <div class="flex items-center justify-between">
@click="openCreateTemplate">+ Nouveau template</button> <div>
<h1 class="text-3xl font-bold text-green tracking-tight">Gestion des Tâches</h1>
<p class="text-text-muted text-xs mt-1">Organisez vos travaux au jardin avec des listes et des modèles.</p>
</div>
<button class="btn-primary flex items-center gap-2" @click="openCreateTemplate">
<span class="text-lg leading-none">+</span> Template
</button>
</div> </div>
<div v-for="[groupe, label] in groupes" :key="groupe" class="mb-6"> <!-- Section "Créer rapidement" -->
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">{{ label }}</h2> <section class="bg-bg-soft/20 rounded-xl border border-bg-soft overflow-hidden">
<div v-if="!byStatut(groupe).length" class="text-text-muted text-xs pl-2 mb-2"></div> <button
<div v-for="t in byStatut(groupe)" :key="t.id" class="w-full px-5 py-3 flex items-center justify-between text-left hover:bg-bg-soft/30 transition-colors"
class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard"> @click="showQuickSection = !showQuickSection"
<span :class="{ >
'text-red': t.priorite === 'haute', <span class="text-text font-bold text-sm flex items-center gap-2">
'text-yellow': t.priorite === 'normale', <span class="text-yellow"></span> Créer rapidement
'text-text-muted': t.priorite === 'basse' </span>
}"></span> <span class="text-text-muted text-xs flex items-center gap-2">
<div class="flex-1 min-w-0"> <span>{{ totalTemplates }} template{{ totalTemplates > 1 ? 's' : '' }} disponible{{ totalTemplates > 1 ? 's' : '' }}</span>
<div class="text-text text-sm">{{ t.titre }}</div> <span :class="['transition-transform inline-block', showQuickSection ? 'rotate-180' : '']"></span>
<div v-if="t.description" class="text-text-muted text-xs">{{ t.description }}</div> </span>
<div v-if="t.echeance && t.statut !== 'template'" class="text-text-muted text-xs">📅 {{ fmtDate(t.echeance) }}</div> </button>
<div v-if="t.frequence_jours != null && t.frequence_jours > 0" class="text-text-muted text-xs">
🔁 Tous les {{ t.frequence_jours }} jours <div v-show="showQuickSection" class="border-t border-bg-soft divide-y divide-bg-soft/50">
<!-- Mes templates personnalisés -->
<div v-if="byStatut('template').length" class="p-4 space-y-3">
<h3 class="text-text-muted text-[10px] font-bold uppercase tracking-widest">Mes templates</h3>
<div class="flex flex-wrap gap-2">
<button
v-for="t in byStatut('template')" :key="t.id"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium transition-all hover:scale-[1.02]"
:class="priorityChipClass(t.priorite)"
@click="openSchedule(t)"
>
<span>{{ priorityIcon(t.priorite) }}</span>
<span>{{ t.titre }}</span>
<span v-if="t.frequence_jours" class="text-[10px] opacity-70">🔁 {{ t.frequence_jours }}j</span>
<span class="ml-1 opacity-50 text-[10px]"> programmer</span>
</button>
</div> </div>
<div v-if="t.planting_id && t.statut !== 'template'" class="text-text-muted text-xs">🌱 Plantation #{{ t.planting_id }}</div>
</div> </div>
<div class="flex gap-1 items-center shrink-0">
<button v-if="t.statut === 'a_faire'" class="text-xs text-blue hover:underline" <!-- Tâches courantes prédéfinies -->
@click="store.updateStatut(t.id!, 'en_cours')"> En cours</button> <div class="p-4 space-y-3">
<button v-if="t.statut === 'en_cours'" class="text-xs text-green hover:underline" <h3 class="text-text-muted text-[10px] font-bold uppercase tracking-widest">Tâches courantes</h3>
@click="store.updateStatut(t.id!, 'fait')"> Fait</button> <div class="flex flex-wrap gap-2">
<button <button
v-if="t.statut === 'template'" v-for="qt in quickTemplatesFiltered" :key="qt.titre"
@click="startEdit(t)" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-bg-soft bg-bg text-text-muted text-sm hover:text-text hover:border-text-muted transition-all hover:scale-[1.02]"
class="text-xs text-yellow hover:underline ml-2" @click="openScheduleQuick(qt)"
> >
Édit. <span>{{ qt.icone }}</span>
</button> <span>{{ qt.titre }}</span>
<button class="text-xs text-text-muted hover:text-red ml-1" @click="store.remove(t.id!)"></button> </button>
</div>
</div>
</div>
</section>
<!-- Kanban : À faire / En cours / Terminé -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div v-for="[groupe, label] in listGroupes" :key="groupe" class="space-y-3">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span :class="['w-2 h-2 rounded-full', groupeColor(groupe)]"></span>
{{ label }}
<span class="ml-auto bg-bg-soft px-2 py-0.5 rounded text-[10px]">{{ byStatut(groupe).length }}</span>
</h2>
<div v-if="!byStatut(groupe).length" class="card-jardin text-center py-8 opacity-30 border-dashed">
<p class="text-text-muted text-xs uppercase tracking-widest font-bold">Aucune tâche</p>
</div>
<div v-for="t in byStatut(groupe)" :key="t.id"
class="card-jardin flex items-center gap-4 group relative overflow-hidden">
<!-- Barre priorité -->
<div :class="[
'absolute left-0 top-0 bottom-0 w-1',
t.priorite === 'haute' ? 'bg-red' : t.priorite === 'normale' ? 'bg-yellow' : 'bg-bg-hard'
]"></div>
<div class="flex-1 min-w-0 pl-1">
<div class="flex items-center gap-2 mb-1">
<span class="text-text font-bold text-sm">{{ t.titre }}</span>
<span v-if="t.frequence_jours" class="badge badge-aqua !text-[8px]">🔁 {{ freqLabel(t.frequence_jours) }}</span>
</div>
<div v-if="t.description" class="text-text-muted text-xs line-clamp-1 mb-1 italic">{{ t.description }}</div>
<div class="flex flex-wrap gap-3 text-[10px] font-bold uppercase tracking-tighter text-text-muted opacity-70">
<span v-if="t.echeance" class="flex items-center gap-1">📅 {{ fmtDate(t.echeance) }}</span>
<span v-if="t.planting_id" class="flex items-center gap-1">🌱 {{ plantingShortLabel(t.planting_id) }}</span>
</div>
</div>
<div class="flex gap-2 items-center shrink-0">
<button v-if="t.statut === 'a_faire'" class="btn-outline !py-1 !px-2 text-blue border-blue/20 hover:bg-blue/10 text-[10px] font-bold uppercase"
@click="updateStatut(t.id!, 'en_cours')">Démarrer</button>
<button v-if="t.statut === 'en_cours'" class="btn-outline !py-1 !px-2 text-green border-green/20 hover:bg-green/10 text-[10px] font-bold uppercase"
@click="updateStatut(t.id!, 'fait')">Terminer</button>
<button @click="removeTask(t.id!)"
class="p-1.5 text-text-muted hover:text-red transition-colors opacity-0 group-hover:opacity-100"></button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal création / édition --> <!-- Bibliothèque de templates -->
<section v-if="byStatut('template').length" class="space-y-3">
<h2 class="text-text-muted text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-aqua"></span>
Bibliothèque de templates
<span class="ml-auto bg-bg-soft px-2 py-0.5 rounded text-[10px]">{{ byStatut('template').length }}</span>
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
<div v-for="t in byStatut('template')" :key="t.id"
class="card-jardin flex items-center gap-3 group relative overflow-hidden">
<div :class="[
'absolute left-0 top-0 bottom-0 w-1',
t.priorite === 'haute' ? 'bg-red' : t.priorite === 'normale' ? 'bg-yellow' : 'bg-bg-hard'
]"></div>
<div class="flex-1 min-w-0 pl-1">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-text font-bold text-sm">{{ t.titre }}</span>
<span v-if="t.frequence_jours" class="badge badge-aqua !text-[8px]">🔁 {{ freqLabel(t.frequence_jours) }}</span>
</div>
<div v-if="t.description" class="text-text-muted text-xs line-clamp-1 italic">{{ t.description }}</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
class="btn-outline !py-1 !px-2 text-aqua border-aqua/20 hover:bg-aqua/10 text-[10px] font-bold uppercase"
@click="openSchedule(t)"
>Programmer</button>
<button @click="startEdit(t)"
class="p-1.5 text-text-muted hover:text-yellow transition-colors opacity-0 group-hover:opacity-100"></button>
<button @click="removeTask(t.id!)"
class="p-1.5 text-text-muted hover:text-red transition-colors opacity-0 group-hover:opacity-100"></button>
</div>
</div>
</div>
</section>
<!-- Modal création / édition template -->
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm"> <div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft"> <div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft">
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier la tâche' : 'Nouveau template' }}</h2> <h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier le template' : 'Nouveau template' }}</h2>
<form @submit.prevent="submit" class="grid gap-3"> <form @submit.prevent="submit" class="grid gap-3">
<div> <div>
<label class="text-text-muted text-xs block mb-1">Titre *</label> <label class="text-text-muted text-xs block mb-1">Titre *</label>
@@ -54,93 +160,328 @@
</div> </div>
<div> <div>
<label class="text-text-muted text-xs block mb-1">Description</label> <label class="text-text-muted text-xs block mb-1">Description</label>
<textarea v-model="form.description" rows="2" <textarea v-model="form.description" rows="1"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none resize-none" /> @input="autoResize"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none resize-none transition-all overflow-hidden" />
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div>
<div> <label class="text-text-muted text-xs block mb-1">Priorité</label>
<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">
<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="basse">Basse</option> <option value="normale">Normale</option>
<option value="normale">Normale</option> <option value="haute">Haute</option>
<option value="haute">Haute</option> </select>
</select>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Type</label>
<input value="Template" readonly
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text-muted text-sm" />
</div>
</div> </div>
<div class="bg-bg rounded border border-bg-hard p-3"> <div class="bg-bg rounded border border-bg-hard p-3">
<label class="inline-flex items-center gap-2 text-sm text-text"> <label class="inline-flex items-center gap-2 text-sm text-text cursor-pointer">
<input v-model="form.repetition" type="checkbox" class="accent-green" /> <input v-model="form.repetition" type="checkbox" class="accent-green" />
Répétition Répétition périodique
</label> </label>
<p class="text-text-muted text-[11px] mt-1">Fréquence proposée quand la tâche est ajoutée depuis une plantation.</p> <p class="text-text-muted text-[11px] mt-1">Fréquence proposée quand la tâche est programmée depuis un template.</p>
</div> </div>
<div v-if="form.repetition"> <div v-if="form.repetition" class="flex gap-2 items-center">
<label class="text-text-muted text-xs block mb-1">Fréquence (jours)</label> <input v-model.number="form.freq_nb" type="number" min="1" max="99" required placeholder="1"
<input class="w-20 bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none font-mono" />
v-model.number="form.frequence_jours" <select v-model="form.freq_unite" class="flex-1 bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
type="number" <option value="jours">Jour(s)</option>
min="1" <option value="semaines">Semaine(s)</option>
step="1" <option value="mois">Mois</option>
required </select>
placeholder="Ex: 7" <span class="text-text-muted text-xs whitespace-nowrap">= {{ formFreqEnJours }} j</span>
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 class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold"> <button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold" :disabled="submitting">
{{ editId ? 'Enregistrer' : 'Créer le template' }} {{ submitting ? 'Enregistrement…' : (editId ? 'Enregistrer' : 'Créer le template') }}
</button> </button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="closeForm">Annuler</button> <button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="closeForm">Annuler</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Modal "Programmer une tâche" -->
<div v-if="showScheduleModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeSchedule">
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-sm border border-bg-soft max-h-[90vh] overflow-y-auto">
<h2 class="text-text font-bold text-lg mb-1">Programmer une tâche</h2>
<p class="text-text-muted text-xs mb-5 italic">
Crée une tâche <span class="text-blue font-bold">À faire</span> depuis ce template.
</p>
<form @submit.prevent="createScheduled" class="grid gap-3">
<!-- Titre -->
<div>
<label class="text-text-muted text-xs block mb-1">Titre *</label>
<input v-model="scheduleForm.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>
<!-- Date de démarrage -->
<div>
<label class="text-text-muted text-xs block mb-1">
Date de démarrage
<span class="opacity-50">(par défaut : aujourd'hui)</span>
</label>
<input v-model="scheduleForm.date_debut" 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>
<!-- Priorité -->
<div>
<label class="text-text-muted text-xs block mb-1">Priorité</label>
<select v-model="scheduleForm.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>
<!-- Plantation liée -->
<div>
<label class="text-text-muted text-xs block mb-1">
Plantation liée
<span class="opacity-50">(optionnel)</span>
</label>
<select v-model="scheduleForm.planting_id"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none">
<option :value="null">— Aucune plantation —</option>
<optgroup v-for="g in plantingsByGarden" :key="g.gardenId" :label="g.gardenName">
<option v-for="p in g.plantings" :key="p.id" :value="p.id">
{{ plantingLabel(p) }}
</option>
</optgroup>
</select>
</div>
<!-- Répétition -->
<div class="bg-bg rounded border border-bg-hard p-3 space-y-2">
<label class="inline-flex items-center gap-2 text-sm text-text cursor-pointer">
<input v-model="scheduleForm.repetition" type="checkbox" class="accent-green" />
Répétition périodique
</label>
<div v-if="scheduleForm.repetition" class="flex gap-2 items-center pt-1">
<span class="text-text-muted text-xs shrink-0">Tous les</span>
<input v-model.number="scheduleForm.freq_nb" type="number" min="1" max="99"
class="w-16 bg-bg-soft border border-bg-soft rounded px-2 py-1.5 text-text text-sm focus:border-green outline-none font-mono text-center" />
<select v-model="scheduleForm.freq_unite"
class="flex-1 bg-bg-soft border border-bg-soft rounded px-2 py-1.5 text-text text-sm focus:border-green outline-none">
<option value="jours">Jour(s)</option>
<option value="semaines">Semaine(s)</option>
<option value="mois">Mois</option>
</select>
</div>
<p v-if="scheduleForm.repetition" class="text-text-muted text-[11px]">
→ Récurrence de {{ scheduleFreqEnJours }} jour{{ scheduleFreqEnJours > 1 ? 's' : '' }}
</p>
</div>
<!-- Notes -->
<div>
<label class="text-text-muted text-xs block mb-1">Notes <span class="opacity-50">(optionnel)</span></label>
<textarea v-model="scheduleForm.notes" rows="2" placeholder="Précisions sur cette occurrence…"
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none resize-none" />
</div>
<div class="flex gap-2 mt-1">
<button type="submit" class="bg-blue text-bg px-4 py-2 rounded text-sm font-semibold flex-1" :disabled="submitting">
{{ submitting ? 'Création' : 'Créer la tâche' }}
</button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="closeSchedule">Annuler</button>
</div>
</form>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useTasksStore } from '@/stores/tasks' import { useTasksStore } from '@/stores/tasks'
import { usePlantingsStore } from '@/stores/plantings'
import { usePlantsStore } from '@/stores/plants'
import { useGardensStore } from '@/stores/gardens'
import type { Task } from '@/api/tasks' import type { Task } from '@/api/tasks'
import type { Planting } from '@/api/plantings'
import { useToast } from '@/composables/useToast'
const store = useTasksStore() // ── Types ──────────────────────────────────────────────────────────────────────
const showForm = ref(false) interface QuickTemplate {
const editId = ref<number | null>(null) titre: string
icone: string
priorite: string
description?: string
}
// ── Stores & composables ───────────────────────────────────────────────────────
const store = useTasksStore()
const plantingsStore = usePlantingsStore()
const plantsStore = usePlantsStore()
const gardensStore = useGardensStore()
const toast = useToast()
// ── État UI ────────────────────────────────────────────────────────────────────
const showForm = ref(false)
const showScheduleModal = ref(false)
const showQuickSection = ref(true)
const editId = ref<number | null>(null)
const submitting = ref(false)
// ── Formulaire template (création / édition) ───────────────────────────────────
const form = reactive({ const form = reactive({
titre: '', titre: '',
description: '', description: '',
priorite: 'normale', priorite: 'normale',
statut: 'template',
repetition: false, repetition: false,
frequence_jours: undefined as number | undefined, freq_nb: 1 as number,
freq_unite: 'semaines' as 'jours' | 'semaines' | 'mois',
}) })
const groupes: [string, string][] = [ const formFreqEnJours = computed(() => {
const n = form.freq_nb || 1
if (form.freq_unite === 'semaines') return n * 7
if (form.freq_unite === 'mois') return n * 30
return n
})
// ── Formulaire "programmer" (instancier un template) ──────────────────────────
const scheduleForm = reactive({
titre: '',
date_debut: today(),
notes: '',
priorite: 'normale',
planting_id: null as number | null,
repetition: false,
freq_nb: 1 as number,
freq_unite: 'semaines' as 'jours' | 'semaines' | 'mois',
})
const scheduleFreqEnJours = computed(() => {
const n = scheduleForm.freq_nb || 1
if (scheduleForm.freq_unite === 'semaines') return n * 7
if (scheduleForm.freq_unite === 'mois') return n * 30
return n
})
// ── Templates prédéfinis jardinage ─────────────────────────────────────────────
const QUICK_TEMPLATES: QuickTemplate[] = [
{ titre: 'Arrosage', icone: '💧', priorite: 'normale' },
{ titre: 'Semis en intérieur', icone: '🌱', priorite: 'normale' },
{ titre: 'Semis en pleine terre', icone: '🌾', priorite: 'normale' },
{ titre: 'Repiquage / Transplantation',icone: '🪴', priorite: 'normale' },
{ titre: 'Récolte', icone: '🥕', priorite: 'normale' },
{ titre: 'Taille / Ébourgeonnage', icone: '', priorite: 'normale' },
{ titre: 'Désherbage', icone: '🌿', priorite: 'basse' },
{ titre: 'Fertilisation / Amendement', icone: '💊', priorite: 'normale' },
{ titre: 'Traitement phytosanitaire', icone: '🧪', priorite: 'haute' },
{ titre: 'Observation / Relevé', icone: '👁', priorite: 'basse' },
{ titre: 'Paillage', icone: '🍂', priorite: 'basse' },
{ titre: 'Compostage', icone: '', priorite: 'basse' },
{ titre: 'Buttage', icone: '', priorite: 'normale' },
{ titre: 'Protection gel / Voile', icone: '🌡', priorite: 'haute' },
{ titre: 'Tuteurage', icone: '🪵', priorite: 'normale' },
{ titre: 'Éclaircissage', icone: '🌞', priorite: 'normale' },
]
const quickTemplatesFiltered = computed(() => {
const existing = new Set(byStatut('template').map(t => t.titre.toLowerCase().trim()))
return QUICK_TEMPLATES.filter(qt => !existing.has(qt.titre.toLowerCase().trim()))
})
const totalTemplates = computed(
() => byStatut('template').length + quickTemplatesFiltered.value.length
)
// ── Plantations groupées par jardin pour le <optgroup> ─────────────────────────
const plantingsByGarden = computed(() => {
const gardens = gardensStore.gardens
const plantings = plantingsStore.plantings.filter(p => p.statut !== 'termine' && p.statut !== 'echoue')
const groups: { gardenId: number; gardenName: string; plantings: Planting[] }[] = []
for (const g of gardens) {
const gPlantings = plantings.filter(p => p.garden_id === g.id)
if (gPlantings.length) {
groups.push({ gardenId: g.id!, gardenName: g.nom, plantings: gPlantings })
}
}
// Plantations sans jardin reconnu
const knownGardenIds = new Set(gardens.map(g => g.id))
const orphans = plantings.filter(p => !knownGardenIds.has(p.garden_id))
if (orphans.length) {
groups.push({ gardenId: 0, gardenName: 'Autres', plantings: orphans })
}
return groups
})
function plantingLabel(p: Planting): string {
const plant = plantsStore.plants.find(pl => pl.id === p.variety_id)
const nom = plant
? [plant.nom_commun, plant.variete].filter(Boolean).join(' ')
: `Variété #${p.variety_id}`
const date = p.date_plantation ? ` (${fmtDate(p.date_plantation)})` : ''
return `${nom}${date}`
}
function plantingShortLabel(id: number): string {
const p = plantingsStore.plantings.find(x => x.id === id)
if (!p) return `#${id}`
const plant = plantsStore.plants.find(pl => pl.id === p.variety_id)
return plant?.nom_commun ?? `#${id}`
}
// ── Groupes Kanban ─────────────────────────────────────────────────────────────
const listGroupes: [string, string][] = [
['a_faire', 'À faire'], ['a_faire', 'À faire'],
['en_cours', 'En cours'], ['en_cours', 'En cours'],
['fait', 'Terminé'], ['fait', 'Terminé'],
['template', 'Templates'],
] ]
function groupeColor(g: string) {
const map: Record<string, string> = { a_faire: 'bg-blue', en_cours: 'bg-yellow', fait: 'bg-green' }
return map[g] ?? 'bg-bg-hard'
}
const byStatut = (s: string) => store.tasks.filter(t => t.statut === s) const byStatut = (s: string) => store.tasks.filter(t => t.statut === s)
// ── Helpers ────────────────────────────────────────────────────────────────────
function today() {
return new Date().toISOString().slice(0, 10)
}
function fmtDate(s: string) { function fmtDate(s: string) {
return new Date(s + 'T12:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) return new Date(s + 'T12:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
} }
function freqLabel(jours: number): string {
if (jours % 30 === 0 && jours >= 30) return `${jours / 30}mois`
if (jours % 7 === 0 && jours >= 7) return `${jours / 7}sem`
return `${jours}j`
}
function priorityIcon(p: string) {
return { haute: '🔴', normale: '🟡', basse: '' }[p] ?? ''
}
function priorityChipClass(p: string) {
const map: Record<string, string> = {
haute: 'border-red/30 bg-red/10 text-red hover:bg-red/20',
normale: 'border-yellow/30 bg-yellow/10 text-yellow hover:bg-yellow/20',
basse: 'border-bg-soft bg-bg text-text-muted hover:bg-bg-soft',
}
return map[p] ?? map.basse
}
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
// ── Gestion template (form création/édition) ───────────────────────────────────
function resetForm() { function resetForm() {
Object.assign(form, { Object.assign(form, {
titre: '', titre: '', description: '', priorite: 'normale',
description: '', repetition: false, freq_nb: 1, freq_unite: 'semaines',
priorite: 'normale',
statut: 'template',
repetition: false,
frequence_jours: undefined,
}) })
} }
@@ -152,13 +493,19 @@ function openCreateTemplate() {
function startEdit(t: Task) { function startEdit(t: Task) {
editId.value = t.id! editId.value = t.id!
const jours = t.frequence_jours ?? 0
let freq_nb = jours
let freq_unite: 'jours' | 'semaines' | 'mois' = 'jours'
if (jours >= 30 && jours % 30 === 0) { freq_nb = jours / 30; freq_unite = 'mois' }
else if (jours >= 7 && jours % 7 === 0) { freq_nb = jours / 7; freq_unite = 'semaines' }
Object.assign(form, { Object.assign(form, {
titre: t.titre, titre: t.titre,
description: t.description || '', description: t.description || '',
priorite: t.priorite, priorite: t.priorite,
statut: t.statut || 'template',
repetition: Boolean(t.recurrence || t.frequence_jours), repetition: Boolean(t.recurrence || t.frequence_jours),
frequence_jours: t.frequence_jours ?? undefined, freq_nb: freq_nb || 1,
freq_unite,
}) })
showForm.value = true showForm.value = true
} }
@@ -168,25 +515,130 @@ function closeForm() {
editId.value = null editId.value = null
} }
onMounted(() => store.fetchAll())
async function submit() { async function submit() {
if (submitting.value) return
submitting.value = true
const freqJours = form.repetition ? formFreqEnJours.value : null
const payload = { const payload = {
titre: form.titre, titre: form.titre,
description: form.description, description: form.description || undefined,
priorite: form.priorite, priorite: form.priorite,
statut: 'template', statut: 'template',
recurrence: form.repetition ? 'jours' : null, recurrence: form.repetition ? 'jours' : null,
frequence_jours: form.repetition ? (form.frequence_jours ?? 7) : null, frequence_jours: freqJours,
echeance: undefined,
planting_id: undefined,
} }
if (editId.value) { try {
await store.update(editId.value, payload) if (editId.value) {
} else { await store.update(editId.value, payload)
await store.create(payload) toast.success('Template modifié')
} else {
await store.create(payload)
toast.success('Template créé')
}
closeForm()
resetForm()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
} }
closeForm()
resetForm()
} }
// ── Programmer une tâche depuis un template ────────────────────────────────────
function resetScheduleForm() {
Object.assign(scheduleForm, {
titre: '',
date_debut: today(),
notes: '',
priorite: 'normale',
planting_id: null,
repetition: false,
freq_nb: 1,
freq_unite: 'semaines',
})
}
function openSchedule(t: Task) {
resetScheduleForm()
const jours = t.frequence_jours ?? 0
let freq_nb = jours
let freq_unite: 'jours' | 'semaines' | 'mois' = 'jours'
if (jours >= 30 && jours % 30 === 0) { freq_nb = jours / 30; freq_unite = 'mois' }
else if (jours >= 7 && jours % 7 === 0) { freq_nb = jours / 7; freq_unite = 'semaines' }
Object.assign(scheduleForm, {
titre: t.titre,
notes: t.description || '',
priorite: t.priorite,
repetition: Boolean(t.frequence_jours),
freq_nb: freq_nb || 1,
freq_unite,
})
showScheduleModal.value = true
}
function openScheduleQuick(qt: QuickTemplate) {
resetScheduleForm()
Object.assign(scheduleForm, {
titre: `${qt.icone} ${qt.titre}`,
priorite: qt.priorite,
})
showScheduleModal.value = true
}
function closeSchedule() {
showScheduleModal.value = false
}
async function createScheduled() {
if (submitting.value) return
submitting.value = true
const freqJours = scheduleForm.repetition ? scheduleFreqEnJours.value : null
try {
await store.create({
titre: scheduleForm.titre,
description: scheduleForm.notes || undefined,
priorite: scheduleForm.priorite,
statut: 'a_faire',
echeance: scheduleForm.date_debut || today(),
planting_id: scheduleForm.planting_id ?? undefined,
recurrence: scheduleForm.repetition ? 'jours' : null,
frequence_jours: freqJours,
})
toast.success(`"${scheduleForm.titre}" ajoutée aux tâches à faire`)
closeSchedule()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
}
}
// ── Actions Kanban ─────────────────────────────────────────────────────────────
async function updateStatut(id: number, statut: string) {
try {
await store.updateStatut(id, statut)
} catch { /* L'intercepteur Axios affiche le message */ }
}
async function removeTask(id: number) {
try {
await store.remove(id)
toast.success('Tâche supprimée')
} catch { /* L'intercepteur Axios affiche le message */ }
}
// ── Initialisation ─────────────────────────────────────────────────────────────
onMounted(async () => {
try {
await Promise.all([
store.fetchAll(),
plantingsStore.fetchAll(),
plantsStore.fetchAll(),
gardensStore.fetchAll(),
])
} catch {
toast.error('Impossible de charger les données')
}
})
</script> </script>

View File

@@ -6,7 +6,7 @@
"strict": true, "strict": true,
"jsx": "preserve", "jsx": "preserve",
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"], "types": ["vite/client", "vite-plugin-pwa/client"],
"skipLibCheck": true, "skipLibCheck": true,
"paths": { "@/*": ["./src/*"] } "paths": { "@/*": ["./src/*"] }
}, },

View File

@@ -1,9 +1,84 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: 'Jardin — Gestionnaire de potager',
short_name: 'Jardin',
description: 'Interface mobile-first pour gérer vos jardins, cultures et calendrier lunaire',
theme_color: '#282828',
background_color: '#282828',
display: 'standalone',
lang: 'fr',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 an
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /\/api\/.*$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 // 24h
}
}
},
{
urlPattern: /\/uploads\/.*$/,
handler: 'CacheFirst',
options: {
cacheName: 'uploads-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 jours
}
}
}
]
}
})
],
resolve: { resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
}, },