avant 50
This commit is contained in:
@@ -75,7 +75,9 @@
|
||||
"Bash(docker compose restart:*)",
|
||||
"Bash(docker compose build:*)",
|
||||
"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": [
|
||||
"/home/gilles/Documents/vscode/jardin/frontend/src",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -23,15 +24,22 @@ async def lifespan(app: FastAPI):
|
||||
from app.seed import run_seed
|
||||
run_seed()
|
||||
if ENABLE_SCHEDULER:
|
||||
from app.services.scheduler import setup_scheduler
|
||||
from app.services.scheduler import setup_scheduler, backfill_station_missing_dates
|
||||
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
|
||||
if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER:
|
||||
from app.services.scheduler import scheduler
|
||||
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(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -51,6 +51,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
||||
("boutique_url", "TEXT", None),
|
||||
("tarif_achat", "REAL", None),
|
||||
("date_achat", "TEXT", None),
|
||||
("cell_ids", "TEXT", None), # JSON : liste des IDs de zones (multi-sélect)
|
||||
],
|
||||
"plantvariety": [
|
||||
# ancien nom de table → migration vers "plant" si présente
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -7,6 +9,7 @@ class PlantingCreate(SQLModel):
|
||||
garden_id: int
|
||||
variety_id: int
|
||||
cell_id: Optional[int] = None
|
||||
cell_ids: Optional[List[int]] = None # multi-sélect zones
|
||||
date_semis: Optional[date] = None
|
||||
date_plantation: 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)
|
||||
variety_id: int = Field(foreign_key="plant.id", index=True)
|
||||
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_plantation: Optional[date] = None
|
||||
date_repiquage: Optional[date] = None
|
||||
|
||||
@@ -115,6 +115,19 @@ def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_sessio
|
||||
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])
|
||||
def list_measurements(id: int, session: Session = Depends(get_session)):
|
||||
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
|
||||
|
||||
@@ -15,7 +15,11 @@ def list_plantings(session: Session = Depends(get_session)):
|
||||
|
||||
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
|
||||
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.commit()
|
||||
session.refresh(p)
|
||||
@@ -35,7 +39,12 @@ def update_planting(id: int, data: PlantingCreate, session: Session = Depends(ge
|
||||
p = session.get(Planting, id)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
||||
for k, v in data.model_dump(exclude_unset=True).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)
|
||||
p.updated_at = datetime.now(timezone.utc)
|
||||
session.add(p)
|
||||
|
||||
@@ -90,6 +90,70 @@ def _store_open_meteo() -> None:
|
||||
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:
|
||||
"""Configure et démarre le scheduler."""
|
||||
scheduler.add_job(
|
||||
|
||||
@@ -130,45 +130,63 @@ def fetch_current(base_url: str = STATION_URL) -> dict | 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:
|
||||
"""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.
|
||||
"""
|
||||
yesterday = (datetime.now() - timedelta(days=1)).date()
|
||||
year = yesterday.strftime("%Y")
|
||||
month = yesterday.strftime("%m")
|
||||
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
|
||||
month_data = fetch_month_summaries(yesterday.year, yesterday.month, base_url)
|
||||
return month_data.get(yesterday.day)
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
import os
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
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)
|
||||
_NOMS_FR = {
|
||||
"Tomato___healthy": "Tomate (saine)",
|
||||
"Tomato___Early_blight": "Tomate (mildiou précoce)",
|
||||
"Tomato___Late_blight": "Tomate (mildiou tardif)",
|
||||
"Pepper__bell___healthy": "Poivron (sain)",
|
||||
"Apple___healthy": "Pommier (sain)",
|
||||
"Potato___healthy": "Pomme de terre (saine)",
|
||||
"Grape___healthy": "Vigne (saine)",
|
||||
"Corn_(maize)___healthy": "Maïs (sain)",
|
||||
"Strawberry___healthy": "Fraisier (sain)",
|
||||
"Peach___healthy": "Pêcher (sain)",
|
||||
# Mapping complet class_name YOLO → Infos détaillées
|
||||
_DIAGNOSTICS = {
|
||||
"Tomato___healthy": {
|
||||
"label": "Tomate (saine)",
|
||||
"conseil": "Votre plant est en pleine forme. Pensez au paillage pour garder l'humidité.",
|
||||
"actions": ["Pailler le pied", "Vérifier les gourmands"]
|
||||
},
|
||||
"Tomato___Early_blight": {
|
||||
"label": "Tomate (Alternariose)",
|
||||
"conseil": "Champignon fréquent. Retirez les feuilles basses touchées et évitez de mouiller le feuillage.",
|
||||
"actions": ["Retirer feuilles infectées", "Traitement bouillie bordelaise"]
|
||||
},
|
||||
"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]:
|
||||
"""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:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
@@ -36,10 +56,18 @@ async def identify(image_bytes: bytes) -> List[dict]:
|
||||
results = []
|
||||
for det in data[:3]:
|
||||
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({
|
||||
"species": cls.replace("___", " — ").replace("_", " "),
|
||||
"common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")),
|
||||
"species": cls,
|
||||
"common_name": diag["label"],
|
||||
"confidence": det.get("confidence", 0.0),
|
||||
"conseil": diag["conseil"],
|
||||
"actions": diag["actions"],
|
||||
"image_url": "",
|
||||
})
|
||||
return results
|
||||
|
||||
BIN
data/jardin.db
BIN
data/jardin.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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 href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal 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 |
@@ -9,6 +9,9 @@
|
||||
<span>Disk {{ debugDiskLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Notifications toast globales -->
|
||||
<ToastNotification />
|
||||
|
||||
<!-- Mobile: header + drawer -->
|
||||
<AppHeader class="lg:hidden" @toggle-drawer="drawerOpen = !drawerOpen" />
|
||||
<AppDrawer :open="drawerOpen" @close="drawerOpen = false" />
|
||||
@@ -38,8 +41,12 @@
|
||||
</aside>
|
||||
|
||||
<!-- 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)">
|
||||
<RouterView />
|
||||
<main class="pt-14 lg:pt-0 lg:pl-60 min-h-screen w-full bg-bg">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,6 +56,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppDrawer from '@/components/AppDrawer.vue'
|
||||
import ToastNotification from '@/components/ToastNotification.vue'
|
||||
import { meteoApi } from '@/api/meteo'
|
||||
import { settingsApi, type DebugSystemStats } from '@/api/settings'
|
||||
import { applyUiSizesToRoot } from '@/utils/uiSizeDefaults'
|
||||
|
||||
@@ -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 ?? '',
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -56,6 +56,10 @@ export const gardensApi = {
|
||||
},
|
||||
delete: (id: number) => client.delete(`/api/gardens/${id}`),
|
||||
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),
|
||||
addMeasurement: (id: number, m: Partial<Measurement>) =>
|
||||
client.post<Measurement>(`/api/gardens/${id}/measurements`, m).then(r => r.data),
|
||||
|
||||
25
frontend/src/api/identify.ts
Normal file
25
frontend/src/api/identify.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export interface Planting {
|
||||
garden_id: number
|
||||
variety_id: number
|
||||
cell_id?: number
|
||||
cell_ids?: number[] // multi-sélect zones
|
||||
date_plantation?: string
|
||||
quantite: number
|
||||
statut: string
|
||||
|
||||
72
frontend/src/components/DiagnosticModal.vue
Normal file
72
frontend/src/components/DiagnosticModal.vue
Normal 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>
|
||||
56
frontend/src/components/ToastNotification.vue
Normal file
56
frontend/src/components/ToastNotification.vue
Normal 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>
|
||||
38
frontend/src/composables/useToast.ts
Normal file
38
frontend/src/composables/useToast.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,8 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
|
||||
@@ -2,13 +2,56 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-bg text-text font-mono;
|
||||
min-height: 100vh;
|
||||
@layer base {
|
||||
html {
|
||||
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; }
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #1d2021; }
|
||||
::-webkit-scrollbar-thumb { background: #504945; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #665c54; }
|
||||
|
||||
@@ -3,6 +3,8 @@ export const UI_SIZE_DEFAULTS: Record<string, number> = {
|
||||
ui_menu_font_size: 13,
|
||||
ui_menu_icon_size: 18,
|
||||
ui_thumb_size: 96,
|
||||
ui_weather_icon_size: 48,
|
||||
ui_dashboard_icon_size: 24,
|
||||
}
|
||||
|
||||
export function applyUiSizesToRoot(data: Record<string, string | number>): void {
|
||||
|
||||
@@ -1,204 +1,200 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-yellow">💡 Astuces</h1>
|
||||
<button @click="openCreate" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
|
||||
+ Ajouter
|
||||
<div class="p-4 max-w-[1800px] mx-auto space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-yellow tracking-tight">Astuces & Conseils</h1>
|
||||
<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>
|
||||
</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
|
||||
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="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>
|
||||
<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="filterTag"
|
||||
placeholder="Filtrer par tag..."
|
||||
class="bg-bg border border-bg-hard rounded px-3 py-1 text-text text-xs outline-none focus:border-yellow w-44"
|
||||
/>
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 opacity-40">🔍</span>
|
||||
<input
|
||||
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
|
||||
@click="filterMoisActuel = !filterMoisActuel"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium transition-colors border',
|
||||
filterMoisActuel ? 'bg-green/20 text-green border-green/40' : 'border-bg-hard text-text-muted',
|
||||
'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 shadow-lg' : 'border-bg-soft text-text-muted hover:text-text',
|
||||
]"
|
||||
>
|
||||
📅 Ce mois
|
||||
</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 v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
|
||||
<div v-else-if="!store.astuces.length" class="text-text-muted text-sm py-6">Aucune astuce pour ce filtre.</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-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
|
||||
v-for="a in store.astuces"
|
||||
: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">
|
||||
<h2 class="text-text font-semibold leading-tight">{{ a.titre }}</h2>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button @click="openEdit(a)" class="text-yellow text-xs hover:underline">Édit.</button>
|
||||
<button @click="removeAstuce(a.id)" class="text-red text-xs hover:underline">Suppr.</button>
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-text font-bold text-base group-hover:text-yellow transition-colors leading-tight truncate" :title="a.titre">{{ a.titre }}</h2>
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<img
|
||||
v-for="(url, idx) in parseMediaUrls(a.photos)"
|
||||
:key="`astuce-photo-${a.id}-${idx}`"
|
||||
:src="url"
|
||||
alt="photo astuce"
|
||||
class="w-full h-20 object-cover rounded-md border border-bg-hard"
|
||||
/>
|
||||
<!-- Médias compacts -->
|
||||
<div v-if="parseMediaUrls(a.photos).length" class="mb-4 grid grid-cols-3 gap-1.5">
|
||||
<div
|
||||
v-for="(url, idx) in parseMediaUrls(a.photos).slice(0, 3)"
|
||||
:key="idx"
|
||||
class="relative aspect-square rounded-lg overflow-hidden border border-bg-hard shadow-sm"
|
||||
>
|
||||
<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 v-if="parseMediaUrls(a.videos).length" class="mt-3 space-y-2">
|
||||
<video
|
||||
v-for="(url, idx) in parseMediaUrls(a.videos)"
|
||||
:key="`astuce-video-${a.id}-${idx}`"
|
||||
:src="url"
|
||||
controls
|
||||
muted
|
||||
class="w-full rounded-md border border-bg-hard bg-black/40 max-h-52"
|
||||
/>
|
||||
<div v-if="parseMediaUrls(a.videos).length" class="mb-4">
|
||||
<div class="relative aspect-video rounded-xl overflow-hidden border border-bg-hard bg-black/20 group/vid">
|
||||
<video :src="parseMediaUrls(a.videos)[0]" class="w-full h-full object-cover" />
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-100 group-hover/vid:bg-black/20 transition-all">
|
||||
<span class="text-2xl">🎬</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-1">
|
||||
<span v-if="a.categorie" class="text-[11px] bg-yellow/15 text-yellow rounded-full px-2 py-0.5">{{ a.categorie }}</span>
|
||||
<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-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>
|
||||
<!-- Tags -->
|
||||
<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="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">
|
||||
#{{ t }}
|
||||
</span>
|
||||
</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">
|
||||
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-lg border border-bg-soft">
|
||||
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier astuce' : 'Nouvelle astuce' }}</h2>
|
||||
<form @submit.prevent="submitAstuce" class="flex flex-col gap-3">
|
||||
<input
|
||||
v-model="form.titre"
|
||||
placeholder="Titre *"
|
||||
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"
|
||||
/>
|
||||
<!-- Modal Formulaire -->
|
||||
<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-2xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
|
||||
<h2 class="text-text font-bold text-xl uppercase tracking-tighter">{{ editId ? 'Modifier l\'astuce' : 'Nouvelle pépite' }}</h2>
|
||||
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-xl">✕</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="form.contenu"
|
||||
placeholder="Contenu *"
|
||||
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 resize-none h-28"
|
||||
/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<form @submit.prevent="submitAstuce" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Titre accrocheur *</label>
|
||||
<input v-model="form.titre" required placeholder="Ex: Mieux gérer le mildiou..."
|
||||
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>
|
||||
|
||||
<input
|
||||
v-model="form.tagsInput"
|
||||
placeholder="Tags (ex: tomate, semis, 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"
|
||||
/>
|
||||
<div>
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Corps du texte *</label>
|
||||
<textarea v-model="form.contenu" required rows="1" placeholder="Partagez votre savoir-faire..."
|
||||
@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
|
||||
v-model="form.moisInput"
|
||||
placeholder="Mois (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-yellow"
|
||||
/>
|
||||
<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-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">
|
||||
<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">
|
||||
{{ uploadingPhotos ? 'Upload photos...' : 'Ajouter photo(s)' }}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="uploadFiles($event, 'photo')"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Tags (virgule)</label>
|
||||
<input v-model="form.tagsInput" placeholder="tomate, mildiou..."
|
||||
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>
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Mois (virgule)</label>
|
||||
<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 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 ? 'Upload vidéos...' : 'Ajouter vidéo(s)' }}
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="uploadFiles($event, 'video')"
|
||||
/>
|
||||
<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">
|
||||
{{ uploadingVideos ? '...' : '🎬 Vidéos' }}
|
||||
<input type="file" accept="video/*" multiple class="hidden" @change="uploadFiles($event, 'video')" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.photos.length" class="bg-bg border border-bg-soft rounded-lg p-2">
|
||||
<div class="text-xs text-text-muted mb-1">Photos jointes</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div v-for="(url, idx) in form.photos" :key="`form-photo-${idx}`" class="relative group">
|
||||
<img :src="url" alt="photo astuce" class="w-full h-16 object-cover rounded border border-bg-hard" />
|
||||
<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 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 v-if="form.photos.length" class="grid grid-cols-6 gap-2">
|
||||
<div v-for="(url, idx) in form.photos" :key="idx" class="relative group aspect-square">
|
||||
<img :src="url" class="w-full h-full object-cover rounded-lg 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.videos.length" class="bg-bg border border-bg-soft rounded-lg p-2">
|
||||
<div class="text-xs text-text-muted mb-1">Vidéos jointes</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(url, idx) in form.videos" :key="`form-video-${idx}`" class="relative group">
|
||||
<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' }}
|
||||
<div class="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 px-6" @click="closeForm">Annuler</button>
|
||||
<button type="submit" class="btn-primary !bg-yellow !text-bg px-12 py-3 shadow-xl">
|
||||
{{ editId ? 'Sauvegarder' : 'Partager' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -371,6 +367,12 @@ function closeForm() {
|
||||
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() {
|
||||
const payload = {
|
||||
titre: form.titre.trim(),
|
||||
|
||||
@@ -1,109 +1,120 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-green">📷 Bibliothèque</h1>
|
||||
<button @click="showIdentify = true"
|
||||
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
|
||||
Identifier une plante
|
||||
<div class="p-4 max-w-6xl mx-auto space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-green tracking-tight">Bibliothèque Photo</h1>
|
||||
<p class="text-text-muted text-xs mt-1">Gérez vos captures et identifiez vos plantes par IA.</p>
|
||||
</div>
|
||||
<button @click="showIdentify = true" class="btn-primary flex items-center gap-2">
|
||||
<span>🔬</span> Identifier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors',
|
||||
activeFilter === f.val ? 'bg-green text-bg' : 'bg-bg-soft text-text-muted hover:text-text']">
|
||||
: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 shadow-lg' : 'text-text-muted hover:text-text']">
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</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 -->
|
||||
<div v-if="loading" class="text-text-muted text-sm">Chargement...</div>
|
||||
<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-else class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<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)">
|
||||
<img :src="m.thumbnail_url || m.url" :alt="m.titre || ''" 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 class="absolute top-1 left-1 bg-black/60 text-text-muted text-xs px-1 rounded">
|
||||
{{ labelFor(m.entity_type) }}
|
||||
|
||||
<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" />
|
||||
|
||||
<!-- Overlay -->
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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 bg-bg-hard rounded-xl overflow-hidden border border-bg-soft">
|
||||
<img :src="lightbox.url" class="w-full" />
|
||||
<div class="p-4">
|
||||
<!-- Infos identification -->
|
||||
<div v-if="lightbox.identified_species" class="text-center mb-3">
|
||||
<div class="text-green font-semibold text-base">{{ lightbox.identified_common }}</div>
|
||||
<!-- Lightbox stylisée -->
|
||||
<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-2xl w-full bg-bg-hard rounded-3xl overflow-hidden border border-bg-soft shadow-2xl animate-fade-in">
|
||||
<div class="relative aspect-video sm:aspect-square bg-bg overflow-hidden">
|
||||
<img :src="lightbox.url" class="w-full h-full object-contain" />
|
||||
<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>
|
||||
|
||||
<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="text-xs text-text-muted mt-1">
|
||||
Confiance : {{ Math.round((lightbox.identified_confidence || 0) * 100) }}% — via {{ lightbox.identified_source }}
|
||||
<div class="badge badge-green !text-[9px] mt-2">
|
||||
Confiance {{ Math.round((lightbox.identified_confidence || 0) * 100) }}% · via {{ lightbox.identified_source }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lien actuel -->
|
||||
<div class="text-xs text-text-muted mb-3 text-center">
|
||||
{{ labelFor(lightbox.entity_type) }}
|
||||
<span v-if="lightbox.entity_type === 'plante' && plantName(lightbox.entity_id)">
|
||||
: <span class="text-green font-medium">{{ plantName(lightbox.entity_id) }}</span>
|
||||
|
||||
<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">
|
||||
<span>Type :</span>
|
||||
<span class="text-text">{{ labelFor(lightbox.entity_type) }}</span>
|
||||
<span v-if="lightbox.entity_type === 'plante' && plantName(lightbox.entity_id)" class="text-green">
|
||||
({{ plantName(lightbox.entity_id) }})
|
||||
</span>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button @click="startLink(lightbox!)"
|
||||
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 à une plante
|
||||
|
||||
<div class="flex gap-3">
|
||||
<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">
|
||||
🔗 Associer
|
||||
</button>
|
||||
<button
|
||||
@click="toggleAdventice(lightbox!)"
|
||||
:class="[
|
||||
'px-3 py-2 rounded-lg text-xs font-medium transition-colors',
|
||||
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 @click="toggleAdventice(lightbox!)"
|
||||
:class="['btn-outline flex-1 py-3 text-[10px] font-black uppercase tracking-widest',
|
||||
isAdventice(lightbox!) ? 'border-red/20 text-red hover:bg-red/10' : 'border-green/20 text-green hover:bg-green/10']">
|
||||
{{ isAdventice(lightbox!) ? '🪓 Pas Adventice' : '🌾 Adventice' }}
|
||||
</button>
|
||||
<button @click="deleteMedia(lightbox!); lightbox = null"
|
||||
class="bg-red/20 text-red hover:bg-red/30 px-3 py-2 rounded-lg text-xs font-medium transition-colors">
|
||||
🗑 Supprimer
|
||||
<button @click="deleteMedia(lightbox!); lightbox = null" class="btn-outline py-3 px-4 border-red/20 text-red hover:bg-red/10">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
<button class="mt-3 w-full text-text-muted hover:text-text text-sm" @click="lightbox = null">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal associer à une plante -->
|
||||
<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 class="bg-bg-hard rounded-xl p-6 w-full max-w-sm border border-bg-soft">
|
||||
<h3 class="text-text font-bold mb-4">Associer à une plante</h3>
|
||||
<!-- Modal associer -->
|
||||
<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-2xl p-6 w-full max-w-sm border border-bg-soft shadow-2xl">
|
||||
<h3 class="text-text font-black uppercase tracking-tighter text-lg mb-4">Lier à une plante</h3>
|
||||
<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">
|
||||
<option :value="null">-- Choisir une plante --</option>
|
||||
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 dans la liste --</option>
|
||||
<option v-for="p in plantsStore.plants" :key="p.id" :value="p.id">
|
||||
{{ formatPlantLabel(p) }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="linkMedia = null" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
|
||||
<button @click="confirmLink" :disabled="!linkPlantId"
|
||||
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-40">
|
||||
Associer
|
||||
</button>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<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" class="btn-primary px-6 disabled:opacity-30">Confirmer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal identification -->
|
||||
<PhotoIdentifyModal v-if="showIdentify" @close="showIdentify = false" @identified="onIdentified" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,204 +1,229 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-blue mb-4">🌦️ Météo</h1>
|
||||
<div class="p-4 max-w-7xl mx-auto space-y-8">
|
||||
<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">
|
||||
<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)"
|
||||
>
|
||||
◀ Prev
|
||||
</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>
|
||||
<!-- Navigateur -->
|
||||
<div class="flex items-center gap-2 bg-bg-soft/30 p-1 rounded-xl border border-bg-soft">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="stationCurrent"
|
||||
class="bg-bg-soft rounded-xl p-4 border border-bg-hard mb-4 flex flex-wrap gap-4 items-center"
|
||||
>
|
||||
<div>
|
||||
<div class="text-text-muted text-xs mb-1">Température extérieure</div>
|
||||
<div class="text-text text-2xl font-bold">{{ stationCurrent.temp_ext?.toFixed(1) ?? '—' }}°C</div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span v-if="stationCurrent.humidite != null" class="text-blue">💧{{ stationCurrent.humidite }}%</span>
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="currentOpenMeteo?.wmo != null"
|
||||
:src="weatherIcon(currentOpenMeteo.wmo)"
|
||||
class="w-6 h-6"
|
||||
:alt="currentOpenMeteo.label || 'Météo'"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-text-muted text-xs mb-1">Condition actuelle</div>
|
||||
<div class="text-text text-sm">{{ currentOpenMeteo?.label || '—' }}</div>
|
||||
<!-- Widgets Station & Actuel -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<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 class="text-text-muted text-[10px] uppercase font-black tracking-widest mb-1">Température Ext.</div>
|
||||
<div class="text-4xl font-bold text-text">{{ stationCurrent?.temp_ext?.toFixed(1) ?? '--' }}°<span class="text-blue text-xl">C</span></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 class="h-12 w-px bg-bg-hard hidden sm:block"></div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-8 text-center text-blue text-xl">💧</span>
|
||||
<div>
|
||||
<div class="text-[9px] text-text-muted uppercase font-bold tracking-tighter leading-none">Humidité</div>
|
||||
<div class="text-sm font-bold text-text">{{ stationCurrent?.humidite ?? '--' }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-8 text-center text-orange text-xl">💨</span>
|
||||
<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 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 v-if="loadingTableau" class="text-text-muted text-sm py-4">Chargement météo...</div>
|
||||
<div v-else-if="!tableauRows.length" class="text-text-muted text-sm py-4">Pas de données météo.</div>
|
||||
<!-- Tableau Synthétique -->
|
||||
<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="overflow-x-auto bg-bg-soft rounded-xl border border-bg-hard p-2">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="text-text-muted text-xs">
|
||||
<th class="text-left py-2 px-2">Date</th>
|
||||
<th class="text-center py-2 px-2 text-blue" colspan="3">📡 Station locale</th>
|
||||
<th class="text-center py-2 px-2 text-green border-l-2 border-bg-hard" colspan="4">🌐 Open-Meteo</th>
|
||||
<th class="text-center py-2 px-2 text-yellow border-l-2 border-bg-hard" colspan="3">🌙 Lunaire</th>
|
||||
</tr>
|
||||
<tr class="text-text-muted text-xs border-b border-bg-hard">
|
||||
<th class="text-left py-1 px-2"></th>
|
||||
<th class="text-right py-1 px-1">T°min</th>
|
||||
<th class="text-right py-1 px-1">T°max</th>
|
||||
<th class="text-right py-1 px-1">💧mm</th>
|
||||
<div class="card-jardin !p-0 overflow-hidden border-bg-hard/50 shadow-2xl">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-bg-hard/50 text-[10px] font-black uppercase tracking-widest text-text-muted/60 border-b border-bg-hard">
|
||||
<th class="py-4 px-4 text-left">Date</th>
|
||||
<th class="py-4 px-2 text-center text-blue" colspan="3">Station</th>
|
||||
<th class="py-4 px-2 text-center text-green" colspan="4">Prévisions</th>
|
||||
<th class="py-4 px-4 text-right text-yellow">Lune</th>
|
||||
</tr>
|
||||
<tr class="text-[9px] font-bold uppercase tracking-tighter text-text-muted border-b border-bg-hard">
|
||||
<th class="py-2 px-4"></th>
|
||||
<th class="px-1 text-right opacity-60">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-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>
|
||||
<th class="text-right py-1 px-1">T°max</th>
|
||||
<th class="text-right py-1 px-1">💧mm</th>
|
||||
<th class="text-left py-1 px-2">État</th>
|
||||
<td class="py-3 px-4">
|
||||
<div :class="['text-sm font-black font-mono', row.type === 'aujourd_hui' ? 'text-blue' : 'text-text']">
|
||||
{{ formatDate(row.date) }}
|
||||
</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>
|
||||
<th class="text-left py-1 px-2">Mont./Desc.</th>
|
||||
<th class="text-left py-1 px-2">Type jour</th>
|
||||
</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 font-mono text-xs text-blue">{{ stationTMin(row) }}</td>
|
||||
<td class="text-right px-1 font-mono text-xs text-orange font-bold">{{ stationTMax(row) }}</td>
|
||||
<td class="text-right px-1 font-mono text-xs text-aqua">{{ stationRain(row) }}</td>
|
||||
|
||||
<td class="text-right px-1 text-blue text-xs">{{ stationTMin(row) }}</td>
|
||||
<td class="text-right px-1 text-orange text-xs">{{ stationTMax(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-blue border-l border-bg-hard/30">{{ omTMin(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 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="text-right px-1 text-orange text-xs">{{ omTMax(row) }}</td>
|
||||
<td class="text-right px-1 text-blue text-xs">{{ omRain(row) }}</td>
|
||||
<td class="px-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<img
|
||||
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">
|
||||
<div class="flex items-center gap-2">
|
||||
<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" />
|
||||
<span class="text-[11px] font-medium text-text-muted truncate max-w-[85px]">{{ row.open_meteo?.label || '—' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="px-2 text-xs border-l-2 border-bg-hard">
|
||||
{{ lunarForDate(row.date)?.croissante_decroissante || '—' }}
|
||||
</td>
|
||||
<td class="px-2 text-xs">
|
||||
{{ lunarForDate(row.date)?.montante_descendante || '—' }}
|
||||
</td>
|
||||
<td class="px-2 text-xs" :class="typeColor(lunarForDate(row.date)?.type_jour || '')">
|
||||
{{ lunarForDate(row.date)?.type_jour || '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<!-- Élément -->
|
||||
<span :title="lunarForDate(row.date)?.type_jour || ''" class="text-xl filter drop-shadow-sm">
|
||||
{{ typeIcon(lunarForDate(row.date)?.type_jour || '') }}
|
||||
</span>
|
||||
<!-- Mouvement -->
|
||||
<span :title="lunarForDate(row.date)?.montante_descendante || ''" class="text-sm font-bold">
|
||||
{{ movementIcon(lunarForDate(row.date)?.montante_descendante || '') }}
|
||||
</span>
|
||||
<!-- Signe -->
|
||||
<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>
|
||||
|
||||
<aside class="bg-bg-soft rounded-xl border border-bg-hard p-4">
|
||||
<div v-if="!selectedMeteoRow" class="text-text-muted text-sm">Sélectionne un jour dans le tableau pour voir le détail.</div>
|
||||
<!-- Détail Latéral -->
|
||||
<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>
|
||||
<h3 class="text-text font-semibold">{{ formatDateLong(selectedMeteoRow.date) }}</h3>
|
||||
<p class="text-text-muted text-xs">
|
||||
{{ selectedMeteoRow.type === 'passe' ? 'Historique' : selectedMeteoRow.type === 'aujourd_hui' ? 'Aujourd\'hui' : 'Prévision' }}
|
||||
</p>
|
||||
<h3 class="text-text font-black text-lg leading-tight uppercase tracking-tighter">{{ formatDateLong(selectedMeteoRow.date) }}</h3>
|
||||
<span class="badge badge-aqua !text-[9px] mt-1">{{ selectedSaint || 'Sainte Nature' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-t border-bg-hard">
|
||||
<div class="text-blue text-xs font-semibold mb-1">📡 Station locale</div>
|
||||
<div class="text-xs text-text-muted space-y-1">
|
||||
<div v-if="selectedMeteoRow.station && 'temp_ext' in selectedMeteoRow.station && selectedMeteoRow.station.temp_ext != null">
|
||||
T° actuelle: <span class="text-text">{{ selectedMeteoRow.station.temp_ext.toFixed(1) }}°</span>
|
||||
</div>
|
||||
<div>T° min: <span class="text-text">{{ stationTMin(selectedMeteoRow) }}</span></div>
|
||||
<div>T° max/actuelle: <span class="text-text">{{ stationTMax(selectedMeteoRow) }}</span></div>
|
||||
<div>Pluie: <span class="text-text">{{ stationRain(selectedMeteoRow) }}</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 class="space-y-4">
|
||||
<div v-if="selectedLunarDay" class="bg-bg-hard/50 rounded-xl p-3 border border-yellow/10">
|
||||
<div class="text-[9px] font-black uppercase tracking-widest text-yellow mb-2">Cycle Lunaire</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div class="text-text-muted">Type: <span class="text-text font-bold">{{ selectedLunarDay.croissante_decroissante }}</span></div>
|
||||
<div class="text-text-muted">Mouv.: <span class="text-text font-bold">{{ selectedLunarDay.montante_descendante }}</span></div>
|
||||
<div class="text-text-muted">Signe: <span class="text-text font-bold">{{ selectedLunarDay.signe }}</span></div>
|
||||
<div class="text-text-muted">Lumière: <span class="text-text font-bold">{{ selectedLunarDay.illumination }}%</span></div>
|
||||
<div class="text-text-muted col-span-2">Élément: <span class="text-text font-bold">{{ selectedLunarDay.type_jour || '--' }}</span></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">
|
||||
<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 }}"
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-xs text-text-muted">Aucun dicton trouvé pour ce jour.</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>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -210,7 +235,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { lunarApi, type Dicton, type LunarDay } from '@/api/lunar'
|
||||
import { meteoApi, type StationCurrent, type TableauRow } from '@/api/meteo'
|
||||
|
||||
const spanDays = 15
|
||||
const spanDays = 10
|
||||
|
||||
const tableauRows = ref<TableauRow[]>([])
|
||||
const loadingTableau = ref(false)
|
||||
@@ -226,28 +251,19 @@ const rangeStart = computed(() => shiftIso(centerDate.value, -spanDays))
|
||||
const rangeEnd = computed(() => shiftIso(centerDate.value, spanDays))
|
||||
|
||||
const saintsFallback: Record<string, string> = {
|
||||
'04-23': 'Saint Georges',
|
||||
'04-25': 'Saint Marc',
|
||||
'05-11': 'Saint Mamert',
|
||||
'05-12': 'Saint Pancrace',
|
||||
'05-13': 'Saint Servais',
|
||||
'05-14': 'Saint Boniface',
|
||||
'05-19': 'Saint Yves',
|
||||
'05-25': 'Saint Urbain',
|
||||
'04-23': 'Saint Georges', '04-25': 'Saint Marc', '05-11': 'Saint Mamert',
|
||||
'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 selectedLunarDay = computed(() => lunarByDate.value[selectedMeteoDate.value] || null)
|
||||
const currentOpenMeteo = computed(() => {
|
||||
const today = tableauRows.value.find((r) => r.type === 'aujourd_hui')
|
||||
return today?.open_meteo || null
|
||||
})
|
||||
const currentOpenMeteo = computed(() => tableauRows.value.find((r) => r.type === 'aujourd_hui')?.open_meteo || null)
|
||||
|
||||
const selectedSaint = computed(() => {
|
||||
if (!selectedMeteoDate.value) return ''
|
||||
if (selectedLunarDay.value?.saint_du_jour) return selectedLunarDay.value.saint_du_jour
|
||||
const mmdd = selectedMeteoDate.value.slice(5)
|
||||
return saintsFallback[mmdd] || ''
|
||||
return saintsFallback[selectedMeteoDate.value.slice(5)] || ''
|
||||
})
|
||||
|
||||
const selectedDictons = computed(() => {
|
||||
@@ -255,10 +271,8 @@ const selectedDictons = computed(() => {
|
||||
const month = monthFromIso(selectedMeteoDate.value)
|
||||
const day = dayFromIso(selectedMeteoDate.value)
|
||||
const rows = dictonsByMonth.value[month] || []
|
||||
|
||||
const exact = rows.filter((d) => d.jour === day)
|
||||
if (exact.length) return exact
|
||||
return rows.filter((d) => d.jour == null).slice(0, 3)
|
||||
return exact.length ? exact : rows.filter((d) => d.jour == null).slice(0, 3)
|
||||
})
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
function shiftWindow(days: number) {
|
||||
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 shiftWindow(days: number) { 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 selectMeteoDate(isoDate: string) {
|
||||
selectedMeteoDate.value = isoDate
|
||||
@@ -306,124 +309,77 @@ async function ensureDictonsMonth(month: number) {
|
||||
async function loadLunarForTableau() {
|
||||
const months = Array.from(new Set(tableauRows.value.map((r) => r.date.slice(0, 7))))
|
||||
const map: Record<string, LunarDay> = {}
|
||||
|
||||
for (const month of months) {
|
||||
try {
|
||||
const days = await lunarApi.getMonth(month)
|
||||
for (const d of days) map[d.date] = d
|
||||
} catch {
|
||||
// Mois indisponible: on laisse les cellules lunaires vides
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
lunarByDate.value = map
|
||||
}
|
||||
|
||||
async function loadTableau() {
|
||||
loadingTableau.value = true
|
||||
try {
|
||||
const res = await meteoApi.getTableau({
|
||||
center_date: centerDate.value,
|
||||
span: spanDays,
|
||||
})
|
||||
const res = await meteoApi.getTableau({ center_date: centerDate.value, span: spanDays })
|
||||
tableauRows.value = res.rows || []
|
||||
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')
|
||||
if (todayRow) {
|
||||
selectMeteoDate(todayRow.date)
|
||||
} else if (tableauRows.value.length) {
|
||||
selectMeteoDate(tableauRows.value[0].date)
|
||||
}
|
||||
} catch {
|
||||
tableauRows.value = []
|
||||
lunarByDate.value = {}
|
||||
if (todayRow && !selectedMeteoDate.value) selectMeteoDate(todayRow.date)
|
||||
} finally {
|
||||
loadingTableau.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStationCurrent() {
|
||||
try {
|
||||
stationCurrent.value = await meteoApi.getStationCurrent()
|
||||
} catch {
|
||||
stationCurrent.value = null
|
||||
}
|
||||
try { stationCurrent.value = await meteoApi.getStationCurrent() } catch { stationCurrent.value = null }
|
||||
}
|
||||
|
||||
function lunarForDate(isoDate: string): LunarDay | null {
|
||||
return lunarByDate.value[isoDate] || null
|
||||
}
|
||||
function lunarForDate(isoDate: string): LunarDay | null { return lunarByDate.value[isoDate] || null }
|
||||
|
||||
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 '—'
|
||||
return (row.station && 't_min' in row.station && row.station.t_min != null) ? `${row.station.t_min.toFixed(1)}°` : '—'
|
||||
}
|
||||
|
||||
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.type === 'aujourd_hui' && row.station && 'temp_ext' in row.station && row.station.temp_ext != null) {
|
||||
return `${row.station.temp_ext.toFixed(1)}° act.`
|
||||
}
|
||||
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 '—'
|
||||
}
|
||||
|
||||
function stationRain(row: TableauRow): string {
|
||||
if (row.station && row.station.pluie_mm != null) return String(row.station.pluie_mm)
|
||||
return '—'
|
||||
}
|
||||
|
||||
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 stationRain(row: TableauRow): string { return row.station?.pluie_mm != null ? String(row.station.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 {
|
||||
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) =>
|
||||
Math.abs(curr - code) < Math.abs(prev - code) ? curr : prev,
|
||||
)
|
||||
const closest = available.reduce((p, c) => Math.abs(c - code) < Math.abs(p - code) ? c : p)
|
||||
return `/icons/weather/${closest}.svg`
|
||||
}
|
||||
|
||||
function typeColor(type: string): string {
|
||||
return ({ Racine: 'text-yellow', Feuille: 'text-green', Fleur: 'text-orange', Fruit: 'text-red' } as Record<string, string>)[type] || 'text-text-muted'
|
||||
function lunarIcon(illu: number): string {
|
||||
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 {
|
||||
return new Date(`${dateStr}T12:00:00`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
function typeIcon(t: string): string {
|
||||
return ({ Racine: '🥕', Feuille: '🌿', Fleur: '🌸', Fruit: '🍎' } as any)[t] || '—'
|
||||
}
|
||||
|
||||
function formatDateLong(dateStr: string): string {
|
||||
return new Date(`${dateStr}T12:00:00`).toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
function movementIcon(md: string): string { return md === 'Montante' ? '↗️' : md === 'Descendante' ? '↘️' : '' }
|
||||
|
||||
function zodiacIcon(s: string): string {
|
||||
return ({
|
||||
'Bélier': '♈', 'Taureau': '♉', 'Gémeaux': '♊', 'Cancer': '♋',
|
||||
'Lion': '♌', 'Vierge': '♍', 'Balance': '♎', 'Scorpion': '♏',
|
||||
'Sagittaire': '♐', 'Capricorne': '♑', 'Verseau': '♒', 'Poissons': '♓'
|
||||
} as any)[s] || ''
|
||||
}
|
||||
|
||||
watch(centerDate, () => {
|
||||
void loadTableau()
|
||||
})
|
||||
function formatDate(ds: string): string { return new Date(`${ds}T12:00:00`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) }
|
||||
function formatDateLong(ds: string): string { return new Date(`${ds}T12:00:00`).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }) }
|
||||
|
||||
watch(selectedMeteoDate, (iso) => {
|
||||
if (!iso) return
|
||||
void ensureDictonsMonth(monthFromIso(iso))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadTableau()
|
||||
void loadStationCurrent()
|
||||
})
|
||||
watch(centerDate, loadTableau)
|
||||
onMounted(() => { void loadTableau(); void loadStationCurrent() })
|
||||
</script>
|
||||
|
||||
@@ -1,82 +1,147 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-green mb-6">Tableau de bord</h1>
|
||||
|
||||
<section class="mb-6">
|
||||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Tâches à faire</h2>
|
||||
<div v-if="!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 class="p-4 max-w-6xl mx-auto space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-3xl font-bold text-green tracking-tight">Tableau de bord</h1>
|
||||
<div class="text-text-muted text-xs font-medium bg-bg-hard px-3 py-1 rounded-full border border-bg-soft">
|
||||
🌿 {{ new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }) }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mb-6">
|
||||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Météo</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 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 class="text-text-muted text-xs mb-1">Condition actuelle</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
v-if="meteoCurrent?.wmo != null"
|
||||
:src="weatherIcon(meteoCurrent.wmo)"
|
||||
class="w-8 h-8"
|
||||
:alt="meteoCurrent.label || 'Météo'"
|
||||
/>
|
||||
<div class="text-sm text-text">
|
||||
<div>{{ meteoCurrent?.label || '—' }}</div>
|
||||
<div class="text-text-muted text-xs">
|
||||
{{ stationCurrent?.temp_ext != null ? `${stationCurrent.temp_ext.toFixed(1)}°C` : 'Temp. indisponible' }}
|
||||
<span v-if="stationCurrent?.date_heure"> · relevé {{ stationCurrent.date_heure.slice(11, 16) }}</span>
|
||||
<div v-if="!pendingTasks.length" class="card-jardin text-center py-10 opacity-50">
|
||||
<p class="text-text-muted text-sm">Aucune tâche en attente. Profitez du jardin ! ☕</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="t in pendingTasks"
|
||||
:key="t.id"
|
||||
class="card-jardin flex items-center gap-4 group"
|
||||
>
|
||||
<div :class="[
|
||||
'w-2 h-10 rounded-full shrink-0',
|
||||
t.priorite === 'haute' ? 'bg-red' : t.priorite === 'normale' ? 'bg-yellow' : 'bg-bg-hard'
|
||||
]"></div>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<div v-if="meteo7j.length" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-2">
|
||||
<div v-for="day in meteo7j" :key="day.date"
|
||||
class="bg-bg-soft rounded-xl p-3 border border-bg-hard flex flex-col items-center gap-1 min-w-0">
|
||||
<div class="text-text-muted text-xs">{{ formatDate(day.date || '') }}</div>
|
||||
<!-- Section Météo Actuelle -->
|
||||
<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-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
|
||||
v-if="day.wmo != null"
|
||||
: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'"
|
||||
/>
|
||||
<div v-else class="text-2xl">—</div>
|
||||
<div class="text-[11px] text-center text-text-muted leading-tight min-h-[30px]">{{ day.label || '—' }}</div>
|
||||
<div class="flex gap-1 text-xs">
|
||||
<span class="text-orange">↑{{ day.t_max != null ? day.t_max.toFixed(0) : '—' }}°</span>
|
||||
<span class="text-blue">↓{{ day.t_min != null ? day.t_min.toFixed(0) : '—' }}°</span>
|
||||
<div class="text-[10px] text-text-muted font-medium line-clamp-1 h-3">{{ day.label || '—' }}</div>
|
||||
<div class="flex gap-2 text-xs font-bold pt-1">
|
||||
<span class="text-orange">{{ day.t_max?.toFixed(1) }}°</span>
|
||||
<span class="text-blue">{{ day.t_min?.toFixed(1) }}°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-text-muted text-sm py-2">Prévisions indisponibles.</div>
|
||||
</section>
|
||||
|
||||
<!-- Section Jardins -->
|
||||
<section>
|
||||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Jardins</h2>
|
||||
<div v-if="gardensStore.loading" class="text-text-muted text-sm">Chargement...</div>
|
||||
<div
|
||||
v-for="g in gardensStore.gardens"
|
||||
:key="g.id"
|
||||
class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard cursor-pointer hover:border-green transition-colors"
|
||||
@click="router.push(`/jardins/${g.id}`)"
|
||||
>
|
||||
<span class="text-text font-medium">{{ g.nom }}</span>
|
||||
<span class="ml-2 text-xs text-text-muted px-2 py-0.5 bg-bg rounded">{{ g.type }}</span>
|
||||
<div 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-orange"></span>
|
||||
Mes Jardins
|
||||
</h2>
|
||||
<button class="btn-primary py-1 px-4 text-xs" @click="router.push('/jardins')">Gérer</button>
|
||||
</div>
|
||||
|
||||
<div v-if="gardensStore.loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<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 }}m²</span>
|
||||
<div class="text-green text-xl opacity-0 group-hover:opacity-100 transition-all">→</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -84,14 +149,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useGardensStore } from '@/stores/gardens'
|
||||
import { useTasksStore } from '@/stores/tasks'
|
||||
import { meteoApi, type OpenMeteoDay, type StationCurrent } from '@/api/meteo'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const gardensStore = useGardensStore()
|
||||
const tasksStore = useTasksStore()
|
||||
const toast = useToast()
|
||||
const pendingTasks = computed(() => tasksStore.tasks.filter(t => t.statut === 'a_faire').slice(0, 5))
|
||||
|
||||
const meteo7j = ref<OpenMeteoDay[]>([])
|
||||
@@ -100,7 +167,8 @@ const meteoCurrent = computed(() => meteo7j.value[0] || null)
|
||||
|
||||
function formatDate(s: string) {
|
||||
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 {
|
||||
@@ -112,8 +180,11 @@ function weatherIcon(code: number): string {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
gardensStore.fetchAll()
|
||||
tasksStore.fetchAll()
|
||||
// Chargement des données principales (silencieux si erreur)
|
||||
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 { const r = await meteoApi.getPrevisions(7); meteo7j.value = r.days.slice(0, 7) } catch { meteo7j.value = [] }
|
||||
})
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<button class="text-text-muted text-sm mb-4 hover:text-text" @click="router.back()">← Retour</button>
|
||||
|
||||
<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">
|
||||
{{ garden.type }} · {{ garden.exposition ?? 'exposition non définie' }}
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">
|
||||
Grille {{ garden.grille_largeur }}×{{ garden.grille_hauteur }}
|
||||
</h2>
|
||||
<!-- En-tête grille -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<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="grid gap-1 w-max"
|
||||
:style="`grid-template-columns: repeat(${garden.grille_largeur}, 52px)`"
|
||||
:style="`grid-template-columns: repeat(${garden.grille_largeur}, 56px)`"
|
||||
>
|
||||
<div
|
||||
v-for="cell in displayCells" :key="`${cell.row}-${cell.col}`"
|
||||
class="w-[52px] h-[52px] bg-bg-soft border border-bg-hard rounded-md flex items-center justify-center text-xs text-text-muted cursor-pointer hover:border-green transition-colors select-none"
|
||||
:class="{ 'border-orange/60 bg-orange/10 text-orange': cell.etat === 'occupe' }"
|
||||
:title="cell.libelle"
|
||||
:class="[
|
||||
'w-[56px] h-[56px] border rounded-md flex flex-col items-center justify-center text-[10px] select-none transition-all overflow-hidden',
|
||||
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>
|
||||
@@ -66,11 +128,17 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
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 router = useRouter()
|
||||
const garden = ref<Garden | null>(null)
|
||||
const cells = ref<GardenCell[]>([])
|
||||
const plantings = ref<Planting[]>([])
|
||||
const plants = ref<Plant[]>([])
|
||||
const editMode = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const displayCells = computed(() => {
|
||||
if (!garden.value) return []
|
||||
@@ -88,9 +156,87 @@ const displayCells = computed(() => {
|
||||
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 () => {
|
||||
const id = Number(route.params.id)
|
||||
garden.value = await gardensApi.get(id)
|
||||
cells.value = await gardensApi.cells(id)
|
||||
;[garden.value, cells.value, plantings.value, plants.value] = await Promise.all([
|
||||
gardensApi.get(id),
|
||||
gardensApi.cells(id),
|
||||
plantingsApi.list(),
|
||||
plantsApi.list(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,169 +1,243 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-green">🪴 Jardins</h1>
|
||||
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
|
||||
@click="openCreate">+ Nouveau</button>
|
||||
</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 class="p-4 max-w-[1800px] mx-auto space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-green tracking-tight">Mes Jardins</h1>
|
||||
<p class="text-text-muted text-xs mt-1">Gérez vos espaces de culture et leurs dimensions.</p>
|
||||
</div>
|
||||
<button @click="startEdit(g)" class="text-yellow text-xs hover:underline px-2">Édit.</button>
|
||||
<button @click="store.remove(g.id!)" class="text-text-muted hover:text-red text-sm px-2">✕</button>
|
||||
<button class="btn-primary flex items-center gap-2" @click="openCreate">
|
||||
<span class="text-lg leading-none">+</span> Nouveau jardin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.loading && !store.gardens.length" class="text-text-muted text-sm text-center py-8">
|
||||
Aucun jardin. Créez-en un !
|
||||
<div 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">
|
||||
<div v-for="i in 4" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal création / édition -->
|
||||
<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-4xl border border-bg-soft max-h-[90vh] overflow-y-auto">
|
||||
<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">
|
||||
<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-for="g in store.gardens" :key="g.id"
|
||||
class="card-jardin flex flex-col justify-between group relative overflow-hidden h-full min-h-[200px]">
|
||||
|
||||
<!-- 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]">m²</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>
|
||||
<label class="text-text-muted text-xs block mb-1">Nom *</label>
|
||||
<input v-model="form.nom" required
|
||||
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
|
||||
<h2 class="text-text font-black text-2xl uppercase tracking-tighter">{{ editId ? 'Modifier l\'espace' : 'Nouvel espace de culture' }}</h2>
|
||||
<p class="text-text-muted text-xs mt-1 italic">Configurez les paramètres physiques et géographiques de votre jardin.</p>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label class="text-text-muted text-xs block mb-1">Description</label>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Type</label>
|
||||
<select v-model="form.type" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
|
||||
<option value="plein_air">Plein air</option>
|
||||
<option value="serre">Serre</option>
|
||||
<option value="tunnel">Tunnel</option>
|
||||
<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">
|
||||
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-2xl">✕</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
<!-- Colonne 1 : Identité -->
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-yellow font-bold text-xs uppercase tracking-widest flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-yellow"></span> Identité
|
||||
</h3>
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Dimension X (cm)</label>
|
||||
<input v-model.number="form.carre_x_cm" type="number" min="1" step="1"
|
||||
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">Nom de l'espace *</label>
|
||||
<input v-model="form.nom" required placeholder="Ex: Serre de semis"
|
||||
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>
|
||||
<label class="text-text-muted text-xs block mb-1">Dimension Y (cm)</label>
|
||||
<input v-model.number="form.carre_y_cm" type="number" min="1" step="1"
|
||||
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">Description libre</label>
|
||||
<textarea v-model="form.description" rows="1" placeholder="Notes sur l'exposition réelle, l'historique..."
|
||||
@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 class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Largeur grille</label>
|
||||
<input v-model.number="form.grille_largeur" type="number" min="1" max="30"
|
||||
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
|
||||
|
||||
<!-- Colonne 2 : Géométrie & Grille -->
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-aqua font-bold text-xs uppercase tracking-widest flex items-center gap-2">
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Hauteur grille</label>
|
||||
<input v-model.number="form.grille_hauteur" type="number" min="1" max="30"
|
||||
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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 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">Longueur (m)</label>
|
||||
<input v-model.number="form.longueur_m" type="number" min="0" step="0.1"
|
||||
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
|
||||
|
||||
<!-- Colonne 3 : Localisation & Photo -->
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-orange font-bold text-xs uppercase tracking-widest flex items-center gap-2">
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Largeur (m)</label>
|
||||
<input v-model.number="form.largeur_m" type="number" min="0" step="0.1"
|
||||
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">Surface (m²)</label>
|
||||
<input v-model.number="form.surface_m2" type="number" min="0" step="0.1"
|
||||
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
|
||||
|
||||
<div class="bg-bg-soft/30 rounded-3xl p-4 border border-bg-soft">
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-3">Photo de l'espace</label>
|
||||
<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">
|
||||
<input type="file" accept="image/*" @change="onPhotoSelected" class="absolute inset-0 opacity-0 cursor-pointer z-20" />
|
||||
<img v-if="photoPreview" :src="photoPreview" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<div v-else class="text-center">
|
||||
<span class="text-3xl block mb-2">📸</span>
|
||||
<span class="text-[10px] font-bold uppercase text-text-muted">Importer une photo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label class="text-text-muted text-xs block mb-1">Photo parcelle (image)</label>
|
||||
<input type="file" accept="image/*" @change="onPhotoSelected"
|
||||
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
|
||||
<div v-if="photoPreview" class="mt-2">
|
||||
<img :src="photoPreview" alt="Prévisualisation parcelle"
|
||||
class="w-full max-h-44 object-cover rounded border border-bg-hard bg-bg-soft" />
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="md:col-span-2 xl:col-span-3 flex justify-between items-center pt-8 border-t border-bg-soft mt-4">
|
||||
<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 class="flex gap-4">
|
||||
<button type="submit" class="btn-primary px-12 py-4 text-base shadow-xl" :disabled="submitting">
|
||||
{{ submitting ? 'Enregistrement…' : (editId ? 'Sauvegarder les modifications' : 'Créer cet espace de culture') }}
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,10 +249,13 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGardensStore } from '@/stores/gardens'
|
||||
import { gardensApi, type Garden } from '@/api/gardens'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useGardensStore()
|
||||
const toast = useToast()
|
||||
const showForm = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const photoFile = ref<File | null>(null)
|
||||
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() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
|
||||
const autoLongueur =
|
||||
form.carre_potager && form.carre_x_cm != null
|
||||
? Number((form.carre_x_cm / 100).toFixed(2))
|
||||
@@ -280,19 +366,46 @@ async function submit() {
|
||||
sol_type: form.sol_type || undefined,
|
||||
}
|
||||
|
||||
let saved: Garden
|
||||
if (editId.value) {
|
||||
saved = await store.update(editId.value, payload)
|
||||
} else {
|
||||
saved = await store.create(payload)
|
||||
}
|
||||
try {
|
||||
let saved: Garden
|
||||
if (editId.value) {
|
||||
saved = await store.update(editId.value, payload)
|
||||
} else {
|
||||
saved = await store.create(payload)
|
||||
}
|
||||
|
||||
if (photoFile.value && saved.id) {
|
||||
await gardensApi.uploadPhoto(saved.id, photoFile.value)
|
||||
await store.fetchAll()
|
||||
if (photoFile.value && saved.id) {
|
||||
try {
|
||||
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>
|
||||
|
||||
@@ -1,91 +1,129 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-yellow">🔧 Outils</h1>
|
||||
<button @click="openCreate" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">+ Ajouter</button>
|
||||
<div class="p-4 max-w-[1800px] mx-auto space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<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 v-if="toolsStore.loading" class="text-text-muted text-sm">Chargement...</div>
|
||||
<div v-else-if="!toolsStore.tools.length" class="text-text-muted text-sm py-4">Aucun outil enregistré.</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<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-for="i in 6" :key="i" class="card-jardin h-32 animate-pulse opacity-20"></div>
|
||||
</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"
|
||||
class="bg-bg-soft rounded-lg p-4 border border-bg-hard flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-text font-semibold">{{ t.nom }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="startEdit(t)" class="text-yellow text-xs hover:underline">Édit.</button>
|
||||
<button @click="removeTool(t.id!)" class="text-red text-xs hover:underline">Suppr.</button>
|
||||
class="card-jardin group flex flex-col h-full hover:border-yellow/30 transition-all !p-3">
|
||||
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-text font-bold text-base group-hover:text-yellow transition-colors truncate" :title="t.nom">{{ t.nom }}</h2>
|
||||
<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>
|
||||
<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">
|
||||
<img v-if="t.photo_url" :src="t.photo_url" alt="photo outil"
|
||||
class="w-full h-28 object-cover rounded border border-bg-hard bg-bg" />
|
||||
<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" />
|
||||
<p v-if="t.description" class="text-text-muted text-[11px] leading-snug line-clamp-2 mb-3 italic opacity-80">
|
||||
{{ t.description }}
|
||||
</p>
|
||||
|
||||
<!-- 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 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">
|
||||
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier l\'outil' : 'Nouvel outil' }}</h2>
|
||||
<form @submit.prevent="submitTool" class="flex flex-col gap-3">
|
||||
<input v-model="form.nom" placeholder="Nom de l'outil *" 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" />
|
||||
<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="beche">Bêche</option>
|
||||
<option value="fourche">Fourche</option>
|
||||
<option value="griffe">Griffe/Grelinette</option>
|
||||
<option value="arrosage">Arrosage</option>
|
||||
<!-- Modal Formulaire -->
|
||||
<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-2xl p-6 w-full max-w-xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
|
||||
<h2 class="text-text font-bold text-xl">{{ editId ? 'Modifier l\'outil' : 'Nouvel outil' }}</h2>
|
||||
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-xl">✕</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitTool" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom de l'outil *</label>
|
||||
<input v-model="form.nom" required placeholder="Grelinette, Sécateur..."
|
||||
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="autre">Autre</option>
|
||||
</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>
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Photo de l'outil</label>
|
||||
<input type="file" accept="image/*" @change="onPhotoSelected"
|
||||
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full 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" />
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Prix (€)</label>
|
||||
<input v-model.number="form.prix_achat" type="number" step="0.01"
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Vidéo de l'outil</label>
|
||||
<input type="file" accept="video/*" @change="onVideoSelected"
|
||||
class="bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm w-full outline-none focus:border-yellow" />
|
||||
<video v-if="videoPreview" :src="videoPreview" controls muted
|
||||
class="mt-2 w-full h-36 object-cover rounded border border-bg-hard bg-bg" />
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Description</label>
|
||||
<textarea v-model="form.description" rows="1"
|
||||
@input="autoResize"
|
||||
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>
|
||||
<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" />
|
||||
<div class="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-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
|
||||
{{ editId ? 'Enregistrer' : 'Créer' }}
|
||||
|
||||
<!-- Upload Photo -->
|
||||
<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">Photo de l'outil</label>
|
||||
<input type="file" accept="image/*" @change="onPhotoSelected" class="text-[10px] text-text-muted w-full" />
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -159,6 +197,12 @@ function onVideoSelected(event: Event) {
|
||||
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) {
|
||||
editId.value = t.id!
|
||||
Object.assign(form, {
|
||||
|
||||
@@ -1,83 +1,120 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-3xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-green">📆 Planning</h1>
|
||||
<!-- Navigateur 4 semaines -->
|
||||
<div class="flex items-center gap-2">
|
||||
<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 class="p-4 max-w-6xl mx-auto space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-green tracking-tight">Planning</h1>
|
||||
<p class="text-text-muted text-xs mt-1">Visualisez et planifiez vos interventions sur 4 semaines.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-text text-sm font-medium mb-3">{{ periodLabel }}</div>
|
||||
|
||||
<!-- En-tête jours -->
|
||||
<div class="grid grid-cols-7 gap-1 mb-2">
|
||||
<div v-for="dayName in dayHeaders" :key="dayName" class="text-center text-xs py-1 rounded text-text-muted">
|
||||
{{ dayName }}
|
||||
<!-- Navigateur -->
|
||||
<div class="flex items-center gap-2 bg-bg-soft/30 p-1 rounded-xl border border-bg-soft">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Grille 4 semaines -->
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<div v-for="d in periodDays" :key="d.iso"
|
||||
@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 class="flex items-center gap-3">
|
||||
<div class="text-yellow font-bold text-sm tracking-widest uppercase bg-yellow/5 px-4 py-1 rounded-full border border-yellow/10">
|
||||
{{ periodLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Détail jour sélectionné -->
|
||||
<div class="mt-4 bg-bg-soft rounded-lg p-3 border border-bg-hard">
|
||||
<div class="text-text text-sm font-semibold">{{ selectedLabel }}</div>
|
||||
<div class="text-text-muted text-xs mt-0.5">{{ selectedTasks.length }} tâche(s) planifiée(s)</div>
|
||||
<div v-if="!selectedTasks.length" class="text-text-muted text-xs mt-2">Aucune tâche planifiée ce jour.</div>
|
||||
<div v-else class="mt-2 space-y-1">
|
||||
<div v-for="t in selectedTasks" :key="t.id"
|
||||
class="bg-bg rounded px-2 py-1 border border-bg-hard flex items-center gap-2">
|
||||
<span :class="['w-2 h-2 rounded-full shrink-0', dotClass(t.priorite)]"></span>
|
||||
<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>
|
||||
<!-- Calendrier Grid -->
|
||||
<div class="space-y-4">
|
||||
<!-- En-tête jours -->
|
||||
<div class="grid grid-cols-7 gap-3">
|
||||
<div v-for="dayName in dayHeaders" :key="dayName"
|
||||
class="text-center text-[10px] font-black uppercase tracking-[0.2em] text-text-muted/60 pb-2">
|
||||
{{ dayName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Tâches sans date -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">Sans date</h2>
|
||||
<div v-if="!unscheduled.length" class="text-text-muted text-xs pl-2">Toutes les tâches ont une échéance.</div>
|
||||
<div v-for="t in unscheduled" :key="t.id"
|
||||
class="bg-bg-soft rounded-lg p-2 mb-1 border border-bg-hard flex items-center gap-2">
|
||||
<span :class="['text-xs w-2 h-2 rounded-full flex-shrink-0', dotClass(t.priorite)]"></span>
|
||||
<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>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 pt-4">
|
||||
<!-- Détail jour sélectionné -->
|
||||
<section class="lg:col-span-2 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-yellow"></span>
|
||||
Détails du {{ selectedLabel }}
|
||||
</h2>
|
||||
|
||||
<div v-if="!selectedTasks.length" class="card-jardin text-center py-12 opacity-40 border-dashed">
|
||||
<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>
|
||||
</template>
|
||||
@@ -185,19 +222,22 @@ function selectDay(iso: string) {
|
||||
selectedIso.value = iso
|
||||
}
|
||||
|
||||
const priorityClass = (p: string) => ({
|
||||
haute: 'bg-red/20 text-red',
|
||||
normale: 'bg-yellow/20 text-yellow',
|
||||
basse: 'bg-bg-hard text-text-muted',
|
||||
}[p] || 'bg-bg-hard text-text-muted')
|
||||
const priorityBorderClass = (p: string) => ({
|
||||
haute: 'border-red/30 text-red bg-red/5',
|
||||
normale: 'border-yellow/30 text-yellow bg-yellow/5',
|
||||
basse: 'border-bg-soft text-text-muted bg-bg-hard/50',
|
||||
}[p] || 'border-bg-soft text-text-muted')
|
||||
|
||||
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')
|
||||
|
||||
const statutClass = (s: string) => ({
|
||||
a_faire: 'bg-blue/20 text-blue', en_cours: 'bg-green/20 text-green',
|
||||
fait: 'bg-text-muted/20 text-text-muted',
|
||||
a_faire: 'badge-blue',
|
||||
en_cours: 'badge-green',
|
||||
fait: 'badge-text-muted',
|
||||
}[s] || '')
|
||||
|
||||
onMounted(() => store.fetchAll())
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-green">🌱 Plantations</h1>
|
||||
<button @click="showCreate = true"
|
||||
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
|
||||
+ Nouvelle
|
||||
<div class="p-4 max-w-6xl mx-auto space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-green tracking-tight">Plantations</h1>
|
||||
<p class="text-text-muted text-xs mt-1">Suivi de vos cultures, de la plantation à la récolte.</p>
|
||||
</div>
|
||||
<button @click="showCreate = true" class="btn-primary flex items-center gap-2">
|
||||
<span class="text-lg leading-none">+</span> Nouvelle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors',
|
||||
filterStatut === s.val ? 'bg-blue text-bg' : 'bg-bg-soft text-text-muted hover:text-text']">
|
||||
:class="['px-4 py-1.5 rounded-full text-xs font-bold transition-all',
|
||||
filterStatut === s.val ? 'bg-yellow text-bg shadow-lg' : 'text-text-muted hover:text-text']">
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -22,79 +24,93 @@
|
||||
Aucune plantation enregistrée.
|
||||
</div>
|
||||
|
||||
<div v-for="p in filtered" :key="p.id"
|
||||
class="bg-bg-soft rounded-xl mb-3 border border-bg-hard overflow-hidden">
|
||||
<!-- En-tête plantation -->
|
||||
<div class="p-4 flex items-start gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-text font-semibold">{{ plantName(p.variety_id) }}</span>
|
||||
<span class="text-text-muted text-xs">— {{ gardenName(p.garden_id) }}</span>
|
||||
<span :class="['text-xs px-2 py-0.5 rounded-full font-medium', statutClass(p.statut)]">{{ p.statut }}</span>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div v-for="p in filtered" :key="p.id"
|
||||
class="card-jardin group flex flex-col justify-between">
|
||||
|
||||
<div>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h2 class="text-text font-bold text-lg group-hover:text-green transition-colors">{{ plantName(p.variety_id) }}</h2>
|
||||
<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 class="text-text-muted text-xs mt-1 flex gap-3 flex-wrap">
|
||||
<span>{{ p.quantite }} plant(s)</span>
|
||||
<span v-if="p.date_plantation">🌱 {{ fmtDate(p.date_plantation) }}</span>
|
||||
<span v-if="p.boutique_nom">🛒 {{ p.boutique_nom }}</span>
|
||||
<span v-if="p.tarif_achat != null">💶 {{ p.tarif_achat }} €</span>
|
||||
<span v-if="p.date_achat">🧾 {{ fmtDate(p.date_achat) }}</span>
|
||||
<span v-if="p.notes">📝 {{ p.notes }}</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 class="flex items-center gap-1.5">📦 {{ p.quantite }} plants</span>
|
||||
<span v-if="p.date_plantation" class="flex items-center gap-1.5">📅 {{ fmtDate(p.date_plantation) }}</span>
|
||||
<span v-if="p.boutique_nom" class="flex items-center gap-1.5">🛒 {{ p.boutique_nom }}</span>
|
||||
</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 class="flex gap-2 items-center">
|
||||
|
||||
<div class="flex gap-2 pt-3 border-t border-bg-hard/50">
|
||||
<button @click="toggleRecoltes(p.id!)"
|
||||
:class="['text-xs px-2 py-1 rounded transition-colors',
|
||||
openRecoltes === p.id ? 'bg-aqua/20 text-aqua' : 'bg-bg-hard text-text-muted hover:text-aqua']">
|
||||
🍅 Récoltes
|
||||
: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 text-bg border-aqua' : 'border-aqua/20 text-aqua hover:bg-aqua/10']">
|
||||
<span>🍅</span> Récoltes
|
||||
</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)"
|
||||
>
|
||||
➕ Tâche
|
||||
<span>➕</span> Tâche
|
||||
</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>
|
||||
|
||||
<!-- 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="loadingRecoltes" class="text-text-muted text-xs py-2">Chargement...</div>
|
||||
<div v-else>
|
||||
<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>
|
||||
<!-- Section récoltes (dépliable) -->
|
||||
<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-center py-4">
|
||||
<div class="w-6 h-6 border-2 border-aqua/20 border-t-aqua rounded-full animate-spin mx-auto"></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 -->
|
||||
<form @submit.prevent="addRecolte(p.id!)" class="flex gap-2 mt-3 flex-wrap items-end">
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Quantité *</label>
|
||||
<input v-model.number="rForm.quantite" type="number" step="0.1" min="0" required
|
||||
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>
|
||||
<label class="text-text-muted text-xs block mb-1">Unité</label>
|
||||
<select v-model="rForm.unite"
|
||||
class="bg-bg border border-bg-hard rounded px-2 py-1 text-text text-xs focus:border-aqua outline-none">
|
||||
<option>kg</option><option>g</option><option>unites</option><option>litres</option><option>bottes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Date *</label>
|
||||
<input v-model="rForm.date_recolte" type="date" required
|
||||
class="bg-bg border border-bg-hard rounded px-2 py-1 text-text text-xs focus:border-aqua outline-none" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-aqua text-bg px-3 py-1 rounded text-xs font-semibold hover:opacity-90 self-end">
|
||||
+ Ajouter
|
||||
</button>
|
||||
</form>
|
||||
<!-- Formulaire ajout récolte -->
|
||||
<form @submit.prevent="addRecolte(p.id!)" class="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-bg-hard">
|
||||
<div class="col-span-1">
|
||||
<input v-model.number="rForm.quantite" type="number" step="0.1" placeholder="Qté" 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" />
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<select v-model="rForm.unite"
|
||||
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">
|
||||
<option>kg</option><option>g</option><option>unites</option><option>litres</option><option>bottes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<input v-model="rForm.date_recolte" type="date" 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 font-mono" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="col-span-3 bg-aqua text-bg py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-widest hover:opacity-90">
|
||||
Enregistrer la récolte
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,8 +152,9 @@
|
||||
<label class="text-text-muted text-xs block mb-1">Description complémentaire (optionnel)</label>
|
||||
<textarea
|
||||
v-model="taskTemplateForm.extra_description"
|
||||
rows="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 resize-none"
|
||||
rows="1"
|
||||
@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 class="flex gap-2 justify-end">
|
||||
@@ -156,111 +173,203 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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 plantation' : 'Nouvelle plantation' }}</h2>
|
||||
<form @submit.prevent="createPlanting" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Jardin *</label>
|
||||
<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">
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Plante *</label>
|
||||
<select v-model.number="cForm.variety_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">
|
||||
<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 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]">
|
||||
|
||||
<!-- En-tête fixe -->
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-4 border-b border-bg-soft/40 shrink-0">
|
||||
<h2 class="text-text font-bold text-base">{{ editId ? 'Modifier la plantation' : 'Nouvelle plantation' }}</h2>
|
||||
<button type="button" @click="closeCreate" class="text-text-muted hover:text-text text-xl leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Corps scrollable -->
|
||||
<form id="planting-form" @submit.prevent="createPlanting"
|
||||
class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
|
||||
<!-- Jardin + grille zones (pleine largeur) -->
|
||||
<div class="space-y-3">
|
||||
<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" />
|
||||
<label class="text-text-muted text-xs block mb-1">Jardin *</label>
|
||||
<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">
|
||||
<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>
|
||||
<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" />
|
||||
|
||||
<!-- Grille des zones (multi-sélect) -->
|
||||
<div v-if="cForm.garden_id" class="bg-bg/50 rounded-lg p-3 border border-bg-soft/40">
|
||||
<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>
|
||||
<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 class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Nom boutique</label>
|
||||
<input v-model="cForm.boutique_nom" type="text" placeholder="Ex: Graines 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" />
|
||||
|
||||
<!-- 2 colonnes sur desktop -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Colonne gauche : infos essentielles -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Plante *</label>
|
||||
<select v-model.number="cForm.variety_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">
|
||||
<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-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>
|
||||
<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" />
|
||||
|
||||
<!-- Colonne droite : infos achat + notes -->
|
||||
<div class="space-y-3">
|
||||
<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 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>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
<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 { useGardensStore } from '@/stores/gardens'
|
||||
import { usePlantsStore } from '@/stores/plants'
|
||||
import type { Planting } from '@/api/plantings'
|
||||
import { gardensApi, type GardenCell } from '@/api/gardens'
|
||||
import { recoltesApi, type Recolte } from '@/api/recoltes'
|
||||
import { tasksApi, type Task } from '@/api/tasks'
|
||||
import { formatPlantLabel } from '@/utils/plants'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const store = usePlantingsStore()
|
||||
const gardensStore = useGardensStore()
|
||||
const plantsStore = usePlantsStore()
|
||||
const toast = useToast()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const filterStatut = ref('')
|
||||
const submitting = ref(false)
|
||||
const openRecoltes = ref<number | null>(null)
|
||||
const recoltesList = ref<Recolte[]>([])
|
||||
const loadingRecoltes = ref(false)
|
||||
const templates = ref<Task[]>([])
|
||||
const showTaskTemplateModal = ref(false)
|
||||
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 = [
|
||||
{ val: '', label: 'Toutes' },
|
||||
@@ -274,7 +383,8 @@ const cForm = reactive({
|
||||
garden_id: 0, variety_id: 0, quantite: 1,
|
||||
date_plantation: '', statut: 'prevu',
|
||||
boutique_nom: '', boutique_url: '', tarif_achat: undefined as number | undefined, date_achat: '',
|
||||
notes: ''
|
||||
notes: '',
|
||||
cell_ids: [] as number[],
|
||||
})
|
||||
|
||||
const rForm = reactive({
|
||||
@@ -291,11 +401,102 @@ const filtered = computed(() =>
|
||||
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) {
|
||||
const p = plantsStore.plants.find(x => x.id === 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) {
|
||||
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) {
|
||||
const created = await recoltesApi.create(plantingId, { ...rForm })
|
||||
recoltesList.value.push(created)
|
||||
Object.assign(rForm, { quantite: 1, unite: 'kg', date_recolte: new Date().toISOString().slice(0, 10) })
|
||||
try {
|
||||
const created = await recoltesApi.create(plantingId, { ...rForm })
|
||||
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
|
||||
await recoltesApi.delete(id)
|
||||
recoltesList.value = recoltesList.value.filter(r => r.id !== id)
|
||||
try {
|
||||
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]) {
|
||||
@@ -342,11 +559,21 @@ function startEdit(p: typeof store.plantings[0]) {
|
||||
tarif_achat: p.tarif_achat,
|
||||
date_achat: p.date_achat?.slice(0, 10) || '',
|
||||
notes: p.notes || '',
|
||||
cell_ids: p.cell_ids?.length ? [...p.cell_ids] : (p.cell_id ? [p.cell_id] : []),
|
||||
})
|
||||
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() {
|
||||
templates.value = await tasksApi.list({ statut: 'template' })
|
||||
@@ -372,38 +599,55 @@ async function createTaskFromTemplate() {
|
||||
if (!tpl) return
|
||||
const extra = taskTemplateForm.extra_description.trim()
|
||||
const description = [tpl.description || '', extra].filter(Boolean).join('\n\n')
|
||||
await tasksApi.create({
|
||||
titre: tpl.titre,
|
||||
description: description || undefined,
|
||||
garden_id: taskTarget.value.garden_id,
|
||||
planting_id: taskTarget.value.id,
|
||||
priorite: tpl.priorite || 'normale',
|
||||
echeance: taskTemplateForm.echeance || undefined,
|
||||
recurrence: tpl.recurrence ?? null,
|
||||
frequence_jours: tpl.frequence_jours ?? null,
|
||||
statut: 'a_faire',
|
||||
})
|
||||
closeTaskTemplateModal()
|
||||
try {
|
||||
await tasksApi.create({
|
||||
titre: tpl.titre,
|
||||
description: description || undefined,
|
||||
garden_id: taskTarget.value.garden_id,
|
||||
planting_id: taskTarget.value.id,
|
||||
priorite: tpl.priorite || 'normale',
|
||||
echeance: taskTemplateForm.echeance || undefined,
|
||||
recurrence: tpl.recurrence ?? null,
|
||||
frequence_jours: tpl.frequence_jours ?? null,
|
||||
statut: 'a_faire',
|
||||
})
|
||||
toast.success('Tâche créée depuis le template')
|
||||
closeTaskTemplateModal()
|
||||
} catch {
|
||||
// L'intercepteur Axios affiche le message
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlanting() {
|
||||
if (editId.value) {
|
||||
await store.update(editId.value, { ...cForm })
|
||||
} else {
|
||||
await store.create({ ...cForm })
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
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(() => {
|
||||
store.fetchAll()
|
||||
gardensStore.fetchAll()
|
||||
plantsStore.fetchAll()
|
||||
loadTemplates()
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
store.fetchAll(),
|
||||
gardensStore.fetchAll(),
|
||||
plantsStore.fetchAll(),
|
||||
loadTemplates(),
|
||||
])
|
||||
} catch {
|
||||
toast.error('Erreur lors du chargement des données')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,321 +1,306 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-green">🌱 Plantes</h1>
|
||||
<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="p-4 max-w-[1800px] mx-auto space-y-6">
|
||||
<!-- En-tête -->
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<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>
|
||||
|
||||
<!-- Filtres catégorie -->
|
||||
<div class="flex gap-2 mb-4 flex-wrap">
|
||||
<button v-for="cat in categories" :key="cat.val"
|
||||
@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>
|
||||
<!-- Grille de 5 colonnes -->
|
||||
<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">
|
||||
<div v-for="i in 10" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<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-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-for="p in filteredPlants" :key="p.id"
|
||||
class="bg-bg-soft rounded-lg border border-bg-hard overflow-hidden">
|
||||
<!-- En-tête cliquable -->
|
||||
<div class="p-4 flex items-start justify-between gap-4 cursor-pointer"
|
||||
@click="toggleDetail(p.id!)">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="text-text font-semibold">{{ p.nom_commun }}</span>
|
||||
<span v-if="p.variete" class="text-text-muted text-xs">— {{ p.variete }}</span>
|
||||
<span v-if="p.categorie" :class="['text-xs px-2 py-0.5 rounded-full font-medium', catClass(p.categorie)]">{{ catLabel(p.categorie) }}</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>
|
||||
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"
|
||||
:style="{ borderLeftColor: getCatColor(p.categorie || '') }"
|
||||
@click="openDetails(p)">
|
||||
|
||||
<!-- Badge catégorie en haut à gauche -->
|
||||
<div class="absolute top-2 left-2">
|
||||
<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 || '')]">
|
||||
{{ p.categorie }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Panneau détail -->
|
||||
<div v-if="openId === p.id" class="border-t border-bg-hard px-4 pb-4 pt-3">
|
||||
<!-- Notes -->
|
||||
<p v-if="p.notes" class="text-text-muted text-sm mb-3 italic">{{ p.notes }}</p>
|
||||
<div class="p-5 flex-1 flex flex-col justify-center">
|
||||
<h2 class="text-text font-bold text-2xl leading-tight group-hover:text-yellow transition-colors">{{ p.nom_commun }}</h2>
|
||||
<p v-if="p.variete" class="text-text-muted text-[10px] font-black uppercase tracking-widest mt-1 opacity-60">{{ p.variete }}</p>
|
||||
|
||||
<!-- Galerie photos -->
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-text-muted text-xs font-medium uppercase tracking-wide">Photos</span>
|
||||
<button @click="openUpload(p)" class="text-green text-xs hover:underline">+ Ajouter une photo</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPhotos" class="text-text-muted text-xs">Chargement...</div>
|
||||
<div v-else-if="!plantPhotos.length" class="text-text-muted text-xs mb-3">Aucune photo pour cette plante.</div>
|
||||
<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 class="mt-4 flex flex-wrap gap-2">
|
||||
<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-[10px]">📅</span>
|
||||
<span class="text-[10px] font-black text-text-muted">P: {{ p.plantation_mois }}</span>
|
||||
</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">
|
||||
<span class="text-[10px] text-blue">💧</span>
|
||||
<span class="text-[10px] font-black text-text-muted">{{ p.besoin_eau }}</span>
|
||||
</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>
|
||||
|
||||
<!-- Modal formulaire création / édition -->
|
||||
<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 class="bg-bg-hard rounded-xl p-6 w-full max-w-4xl border border-bg-soft max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-text font-bold text-lg mb-4">{{ editPlant ? 'Modifier la plante' : 'Nouvelle plante' }}</h2>
|
||||
<form @submit.prevent="submitPlant" class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<!-- Modale de Détails (Popup) -->
|
||||
<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-3xl w-full max-w-2xl border border-bg-soft shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<!-- Header de la modale -->
|
||||
<div class="p-6 border-b border-bg-soft flex justify-between items-start" :style="{ borderLeft: `8px solid ${getCatColor(detailPlant.categorie || '')}` }">
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Nom commun *</label>
|
||||
<input v-model="form.nom_commun" placeholder="Ex: Tomate" 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" />
|
||||
<p class="text-text-muted text-[11px] mt-1">Nom utilisé au jardin pour identifier rapidement la plante.</p>
|
||||
<span :class="['text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded mb-2 inline-block bg-bg/50', catTextClass(detailPlant.categorie || '')]">
|
||||
{{ detailPlant.categorie }}
|
||||
</span>
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Nom botanique</label>
|
||||
<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" />
|
||||
<p class="text-text-muted text-[11px] mt-1">Nom scientifique utile pour éviter les ambiguïtés.</p>
|
||||
<button @click="detailPlant = null" class="text-text-muted hover:text-red transition-colors text-2xl">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Corps de la modale -->
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Variété</label>
|
||||
<input v-model="form.variete" placeholder="Ex: Andine Cornue"
|
||||
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">Cultivar précis (optionnel).</p>
|
||||
|
||||
<!-- Notes -->
|
||||
<div v-if="detailPlant.notes" class="space-y-2">
|
||||
<h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Conseils & Notes</h3>
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Famille botanique</label>
|
||||
<input v-model="form.famille" placeholder="Ex: Solanacées"
|
||||
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">Permet d'organiser la rotation des cultures.</p>
|
||||
|
||||
<!-- Galerie Photos -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Catégorie</label>
|
||||
<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-green">
|
||||
<option value="">Catégorie</option>
|
||||
<option value="potager">Potager</option>
|
||||
<option value="fleur">Fleur</option>
|
||||
<option value="arbre">Arbre</option>
|
||||
<option value="arbuste">Arbuste</option>
|
||||
<option value="adventice">Adventice (mauvaise herbe)</option>
|
||||
</select>
|
||||
<p class="text-text-muted text-[11px] mt-1">Classe principale pour filtrer la bibliothèque de plantes.</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer de la modale -->
|
||||
<div class="p-4 bg-bg-hard border-t border-bg-soft flex gap-3">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modale Formulaire (Ajout/Edition) -->
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Type de plante</label>
|
||||
<select v-model="form.type_plante"
|
||||
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="">Type</option>
|
||||
<option value="legume">Légume</option>
|
||||
<option value="fruit">Fruit</option>
|
||||
<option value="aromatique">Aromatique</option>
|
||||
<option value="fleur">Fleur</option>
|
||||
<option value="adventice">Adventice</option>
|
||||
</select>
|
||||
<p class="text-text-muted text-[11px] mt-1">Type d'usage de la plante (récolte, ornement, etc.).</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Eau</label>
|
||||
<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="faible">Faible</option>
|
||||
<option value="moyen">Moyen</option>
|
||||
<option value="élevé">Élevé</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
<label class="text-text-muted text-xs block mb-1">Besoin en eau</label>
|
||||
<select v-model="form.besoin_eau"
|
||||
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="">Besoin en eau</option>
|
||||
<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' }}
|
||||
|
||||
<div class="md:col-span-2 flex justify-between items-center pt-6 border-t border-bg-soft mt-4">
|
||||
<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>
|
||||
<button type="submit" class="btn-primary px-12 py-4 text-base shadow-xl !bg-yellow !text-bg">
|
||||
{{ editPlant ? 'Sauvegarder' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal upload photo pour une plante -->
|
||||
<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 class="bg-bg-hard rounded-xl p-6 w-full max-w-sm border border-bg-soft">
|
||||
<h3 class="text-text font-bold mb-4">Photo pour "{{ formatPlantLabel(uploadTarget) }}"</h3>
|
||||
<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>
|
||||
<!-- Lightbox Photo -->
|
||||
<div v-if="lightbox" class="fixed inset-0 bg-black/95 z-[100] flex items-center justify-center p-4" @click="lightbox = null">
|
||||
<img :src="lightbox.url" class="max-w-full max-h-full object-contain rounded-lg shadow-2xl animate-fade-in" />
|
||||
<button class="absolute top-6 right-6 text-white text-4xl hover:text-yellow">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal lier photo existante -->
|
||||
<div v-if="linkTarget" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" @click.self="linkTarget = null">
|
||||
<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>
|
||||
<!-- Upload Photo Trigger (Invisible) -->
|
||||
<input type="file" ref="fileInput" accept="image/*" class="hidden" @change="handleFileUpload" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { usePlantsStore } from '@/stores/plants'
|
||||
import type { Plant } from '@/api/plants'
|
||||
import { formatPlantLabel } from '@/utils/plants'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const plantsStore = usePlantsStore()
|
||||
const toast = useToast()
|
||||
const showForm = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editPlant = ref<Plant | null>(null)
|
||||
const detailPlant = ref<Plant | null>(null)
|
||||
const selectedCat = ref('')
|
||||
const openId = ref<number | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const plantPhotos = ref<Media[]>([])
|
||||
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 fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadTarget = ref<Plant | null>(null)
|
||||
|
||||
interface Media {
|
||||
id: number; entity_type: string; entity_id: number
|
||||
url: string; thumbnail_url?: string; titre?: string
|
||||
identified_species?: string; identified_common?: string
|
||||
identified_confidence?: number; identified_source?: string
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ val: '', label: 'Toutes' },
|
||||
{ val: 'potager', label: '🥕 Potager' },
|
||||
{ val: 'fleur', label: '🌸 Fleur' },
|
||||
{ val: 'arbre', label: '🌳 Arbre' },
|
||||
{ val: 'arbuste', label: '🌿 Arbuste' },
|
||||
{ val: 'adventice', label: '🌾 Adventices' },
|
||||
{ val: '', label: 'TOUTES' },
|
||||
{ val: 'potager', label: '🥕 POTAGER' },
|
||||
{ val: 'fleur', label: '🌸 FLEUR' },
|
||||
{ val: 'arbre', label: '🌳 ARBRE' },
|
||||
{ val: 'arbuste', label: '🌿 ARBUSTE' },
|
||||
{ val: 'adventice', label: '🌾 ADVENTICES' },
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
nom_commun: '', nom_botanique: '', variete: '', famille: '',
|
||||
categorie: '', type_plante: '', besoin_eau: '', besoin_soleil: '',
|
||||
espacement_cm: undefined as number | undefined,
|
||||
temp_min_c: undefined as number | undefined,
|
||||
plantation_mois: '', recolte_mois: '', notes: '',
|
||||
nom_commun: '', variete: '', famille: '',
|
||||
categorie: 'potager', besoin_eau: 'moyen', besoin_soleil: 'plein soleil',
|
||||
plantation_mois: '', notes: '',
|
||||
})
|
||||
|
||||
const filteredPlants = computed(() => {
|
||||
const source = selectedCat.value
|
||||
? plantsStore.plants.filter(p => p.categorie === selectedCat.value)
|
||||
: plantsStore.plants
|
||||
return [...source].sort((a, b) => {
|
||||
const byName = (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr', { sensitivity: 'base' })
|
||||
if (byName !== 0) return byName
|
||||
return (a.variete || '').localeCompare(b.variete || '', 'fr', { sensitivity: 'base' })
|
||||
})
|
||||
let source = plantsStore.plants
|
||||
if (selectedCat.value) source = source.filter(p => p.categorie === selectedCat.value)
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
source = source.filter(p =>
|
||||
p.nom_commun?.toLowerCase().includes(q) ||
|
||||
p.variete?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return [...source].sort((a, b) => (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr'))
|
||||
})
|
||||
|
||||
const catClass = (cat: string) => ({
|
||||
potager: 'bg-green/20 text-green',
|
||||
fleur: 'bg-orange/20 text-orange',
|
||||
arbre: 'bg-blue/20 text-blue',
|
||||
arbuste: 'bg-yellow/20 text-yellow',
|
||||
adventice: 'bg-red/20 text-red',
|
||||
}[cat] || 'bg-bg text-text-muted')
|
||||
function getCatColor(cat: string) {
|
||||
return ({
|
||||
potager: '#b8bb26', fleur: '#fabd2f', arbre: '#83a598',
|
||||
arbuste: '#d3869b', adventice: '#fb4934',
|
||||
} as any)[cat] || '#928374'
|
||||
}
|
||||
|
||||
const catLabel = (cat: string) => ({
|
||||
potager: '🥕 Potager', fleur: '🌸 Fleur', arbre: '🌳 Arbre',
|
||||
arbuste: '🌿 Arbuste', adventice: '🌾 Adventice',
|
||||
}[cat] || cat)
|
||||
function catTextClass(cat: string) {
|
||||
return ({
|
||||
potager: 'text-green', fleur: 'text-yellow', arbre: 'text-blue',
|
||||
arbuste: 'text-purple', adventice: 'text-red',
|
||||
} as any)[cat] || 'text-text-muted'
|
||||
}
|
||||
|
||||
async function toggleDetail(id: number) {
|
||||
if (openId.value === id) { openId.value = null; return }
|
||||
openId.value = id
|
||||
await fetchPhotos(id)
|
||||
async function openDetails(p: Plant) {
|
||||
detailPlant.value = p
|
||||
await fetchPhotos(p.id!)
|
||||
}
|
||||
|
||||
async function fetchPhotos(plantId: number) {
|
||||
@@ -331,86 +316,102 @@ async function fetchPhotos(plantId: number) {
|
||||
}
|
||||
|
||||
function startEdit(p: Plant) {
|
||||
detailPlant.value = null
|
||||
editPlant.value = p
|
||||
Object.assign(form, {
|
||||
nom_commun: p.nom_commun || '', nom_botanique: (p as any).nom_botanique || '',
|
||||
variete: p.variete || '', famille: p.famille || '',
|
||||
categorie: p.categorie || '', type_plante: p.type_plante || '',
|
||||
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 || '',
|
||||
nom_commun: p.nom_commun || '', variete: p.variete || '', famille: p.famille || '',
|
||||
categorie: p.categorie || 'potager', besoin_eau: p.besoin_eau || 'moyen', besoin_soleil: p.besoin_soleil || 'plein soleil',
|
||||
plantation_mois: p.plantation_mois || '', notes: p.notes || '',
|
||||
})
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm.value = false
|
||||
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() {
|
||||
if (editPlant.value) {
|
||||
await axios.put(`/api/plants/${editPlant.value.id}`, { ...form })
|
||||
await plantsStore.fetchAll()
|
||||
} else {
|
||||
await plantsStore.create({ ...form })
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editPlant.value) {
|
||||
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) {
|
||||
if (confirm('Supprimer cette plante ?')) {
|
||||
if (!confirm('Supprimer définitivement cette plante ?')) return
|
||||
try {
|
||||
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]
|
||||
if (!file || !uploadTarget.value) return
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const { data: uploaded } = await axios.post('/api/upload', fd)
|
||||
await axios.post('/api/media', {
|
||||
entity_type: 'plante', entity_id: uploadTarget.value.id,
|
||||
url: uploaded.url, thumbnail_url: uploaded.thumbnail_url,
|
||||
})
|
||||
uploadTarget.value = null
|
||||
if (openId.value) await fetchPhotos(openId.value)
|
||||
|
||||
try {
|
||||
const { data: uploaded } = await axios.post('/api/upload', fd)
|
||||
await axios.post('/api/media', {
|
||||
entity_type: 'plante', entity_id: uploadTarget.value.id,
|
||||
url: uploaded.url, thumbnail_url: uploaded.thumbnail_url,
|
||||
})
|
||||
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) {
|
||||
if (!confirm('Supprimer cette photo ?')) return
|
||||
await axios.delete(`/api/media/${m.id}`)
|
||||
if (openId.value) await fetchPhotos(openId.value)
|
||||
try {
|
||||
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) {
|
||||
linkTarget.value = p
|
||||
selectedLinkPhoto.value = null
|
||||
const { data } = await axios.get<Media[]>('/api/media/all')
|
||||
// Photos non liées à une plante (bibliothèque ou autres)
|
||||
unlinkPhotos.value = data.filter(m => m.entity_type !== 'plante')
|
||||
function autoResize(event: Event) {
|
||||
const el = event.target as HTMLTextAreaElement
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
async function confirmLink() {
|
||||
if (!selectedLinkPhoto.value || !linkTarget.value) return
|
||||
await axios.patch(`/api/media/${selectedLinkPhoto.value}`, {
|
||||
entity_type: 'plante', entity_id: linkTarget.value.id,
|
||||
})
|
||||
const pid = linkTarget.value.id
|
||||
linkTarget.value = null
|
||||
selectedLinkPhoto.value = null
|
||||
if (openId.value === pid) await fetchPhotos(pid)
|
||||
}
|
||||
|
||||
onMounted(() => plantsStore.fetchAll())
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await plantsStore.fetchAll()
|
||||
} catch {
|
||||
toast.error('Impossible de charger les plantes')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,121 +1,181 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-3xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-green mb-4">Réglages</h1>
|
||||
<div class="p-4 max-w-[1800px] mx-auto space-y-8">
|
||||
<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">
|
||||
<h2 class="text-text font-semibold mb-2">Interface</h2>
|
||||
<p class="text-text-muted text-sm mb-4">Ajustez les tailles d'affichage. Les changements sont appliqués instantanément.</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div v-for="s in uiSizeSettings" :key="s.key" class="flex items-center gap-3">
|
||||
<label class="text-sm text-text w-44 shrink-0">{{ s.label }}</label>
|
||||
<input
|
||||
type="range"
|
||||
: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 class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
|
||||
<!-- Section Interface -->
|
||||
<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">
|
||||
<span class="text-2xl">🎨</span>
|
||||
<div>
|
||||
<h2 class="text-text font-bold uppercase tracking-widest text-xs">Interface Graphique</h2>
|
||||
<p class="text-[10px] text-text-muted font-bold">Ajustez les échelles visuelles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
|
||||
:disabled="savingUi"
|
||||
@click="saveUiSettings"
|
||||
>{{ savingUi ? 'Enregistrement...' : 'Enregistrer' }}</button>
|
||||
<button
|
||||
class="text-text-muted text-xs hover:text-text px-2"
|
||||
@click="resetUiSettings"
|
||||
>Réinitialiser</button>
|
||||
<span v-if="uiSavedMsg" class="text-xs text-aqua">{{ uiSavedMsg }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="flex-1 space-y-6">
|
||||
<div v-for="s in uiSizeSettings" :key="s.key" class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-text-muted">{{ s.label }}</label>
|
||||
<span class="text-xs font-mono text-green">{{ uiSizes[s.key] }}{{ s.unit }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="s.min" :max="s.max" :step="s.step"
|
||||
v-model.number="uiSizes[s.key]"
|
||||
class="w-full h-1.5 bg-bg-hard rounded-lg appearance-none cursor-pointer accent-green"
|
||||
@input="applyUiSizes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4">
|
||||
<h2 class="text-text font-semibold mb-2">Général</h2>
|
||||
<p class="text-text-muted text-sm mb-3">Options globales de l'application.</p>
|
||||
<div class="mt-8 pt-4 border-t border-bg-hard flex items-center justify-between">
|
||||
<button
|
||||
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">
|
||||
<input v-model="debugMode" type="checkbox" class="accent-green" />
|
||||
Activer le mode debug (affichage CPU / RAM / disque en header)
|
||||
</label>
|
||||
<!-- Section Général / Debug -->
|
||||
<section class="card-jardin flex flex-col h-full border-yellow/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">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">
|
||||
<button
|
||||
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
|
||||
:disabled="saving"
|
||||
@click="saveSettings"
|
||||
>
|
||||
{{ saving ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</button>
|
||||
<span v-if="savedMsg" class="text-xs text-aqua">{{ savedMsg }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="flex-1 space-y-6">
|
||||
<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">
|
||||
<div class="relative mt-1">
|
||||
<input v-model="debugMode" type="checkbox" class="sr-only peer" />
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-bold text-text group-hover:text-yellow transition-colors">Mode Debug Interactif</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>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4">
|
||||
<h2 class="text-text font-semibold mb-2">Maintenance météo</h2>
|
||||
<p class="text-text-muted text-sm mb-3">Déclenche un rafraîchissement immédiat des jobs météo backend.</p>
|
||||
<button
|
||||
class="bg-blue text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
|
||||
:disabled="refreshingMeteo"
|
||||
@click="refreshMeteo"
|
||||
>
|
||||
{{ refreshingMeteo ? 'Rafraîchissement...' : 'Rafraîchir maintenant' }}
|
||||
</button>
|
||||
</section>
|
||||
<div class="mt-8 pt-4 border-t border-bg-hard flex items-center justify-end gap-3">
|
||||
<span v-if="savedMsg" class="text-[10px] font-bold text-aqua">{{ savedMsg }}</span>
|
||||
<button
|
||||
class="btn-primary !bg-yellow !text-bg !py-2 !px-6 text-xs"
|
||||
:disabled="saving"
|
||||
@click="saveSettings"
|
||||
>
|
||||
{{ saving ? '...' : 'Appliquer' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4">
|
||||
<h2 class="text-text font-semibold mb-2">Test API backend</h2>
|
||||
<p class="text-text-muted text-sm mb-2">
|
||||
Ouvre la documentation interactive de l'API et un test rapide de santé.
|
||||
</p>
|
||||
<p class="text-text-muted text-xs mb-3">Base API détectée: <span class="text-text">{{ apiBaseUrl }}</span></p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="bg-blue text-bg px-3 py-2 rounded-lg text-xs font-semibold hover:opacity-90"
|
||||
@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 Maintenance -->
|
||||
<section class="card-jardin flex flex-col h-full border-blue/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">Maintenance Météo</h2>
|
||||
<p class="text-[10px] text-text-muted font-bold">Synchronisation des données externes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4">
|
||||
<h2 class="text-text font-semibold mb-2">Sauvegarde des données</h2>
|
||||
<p class="text-text-muted text-sm mb-3">
|
||||
Exporte un ZIP téléchargeable contenant la base SQLite, les images/vidéos uploadées et les fichiers texte utiles.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="bg-aqua text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
|
||||
:disabled="downloadingBackup"
|
||||
@click="downloadBackup"
|
||||
>
|
||||
{{ downloadingBackup ? 'Préparation du ZIP...' : 'Télécharger la sauvegarde (.zip)' }}
|
||||
</button>
|
||||
<span v-if="backupMsg" class="text-xs text-aqua">{{ backupMsg }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs text-text-muted leading-relaxed mb-6">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="btn-outline w-full py-4 border-blue/20 text-blue hover:bg-blue/10 flex flex-col items-center gap-2"
|
||||
:disabled="refreshingMeteo"
|
||||
@click="refreshMeteo"
|
||||
>
|
||||
<span class="text-lg">{{ refreshingMeteo ? '🔄' : '⚡' }}</span>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest">{{ refreshingMeteo ? 'Rafraîchissement en cours...' : 'Forcer la mise à jour' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { settingsApi } from '@/api/settings'
|
||||
import { meteoApi } from '@/api/meteo'
|
||||
import { UI_SIZE_DEFAULTS, applyUiSizesToRoot } from '@/utils/uiSizeDefaults'
|
||||
@@ -130,10 +190,12 @@ const apiBaseUrl = detectApiBaseUrl()
|
||||
|
||||
// --- UI Size settings ---
|
||||
const uiSizeSettings = [
|
||||
{ key: 'ui_font_size', label: 'Taille texte', min: 12, max: 20, 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_icon_size', label: 'Icônes menu', min: 14, max: 28, step: 1, unit: 'px' },
|
||||
{ key: 'ui_thumb_size', label: 'Miniatures images/vidéo', min: 60, max: 200, step: 4, 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: 20, 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 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 })
|
||||
@@ -189,17 +251,9 @@ function openInNewTab(path: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function openApiDocs() {
|
||||
openInNewTab('/docs')
|
||||
}
|
||||
|
||||
function openApiRedoc() {
|
||||
openInNewTab('/redoc')
|
||||
}
|
||||
|
||||
function openApiHealth() {
|
||||
openInNewTab('/api/health')
|
||||
}
|
||||
function openApiDocs() { openInNewTab('/docs') }
|
||||
function openApiRedoc() { openInNewTab('/redoc') }
|
||||
function openApiHealth() { openInNewTab('/api/health') }
|
||||
|
||||
function toBool(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
@@ -223,7 +277,7 @@ async function loadSettings() {
|
||||
}
|
||||
applyUiSizes()
|
||||
} catch {
|
||||
// Laisse la valeur locale si l'API n'est pas disponible.
|
||||
// Laisse la valeur locale
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +287,7 @@ async function saveSettings() {
|
||||
try {
|
||||
await settingsApi.update({ debug_mode: debugMode.value ? '1' : '0' })
|
||||
notifyDebugChanged(debugMode.value)
|
||||
savedMsg.value = 'Enregistré'
|
||||
savedMsg.value = 'Pris en compte'
|
||||
window.setTimeout(() => { savedMsg.value = '' }, 1800)
|
||||
} finally {
|
||||
saving.value = false
|
||||
@@ -262,9 +316,9 @@ async function downloadBackup() {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
backupMsg.value = 'Téléchargement lancé.'
|
||||
backupMsg.value = 'ZIP prêt !'
|
||||
} catch {
|
||||
backupMsg.value = 'Erreur lors de la sauvegarde.'
|
||||
backupMsg.value = 'Erreur export.'
|
||||
} finally {
|
||||
downloadingBackup.value = false
|
||||
window.setTimeout(() => { backupMsg.value = '' }, 2200)
|
||||
|
||||
@@ -1,51 +1,157 @@
|
||||
<template>
|
||||
<div class="p-4 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-green">✅ Tâches</h1>
|
||||
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
|
||||
@click="openCreateTemplate">+ Nouveau template</button>
|
||||
<div class="p-4 max-w-6xl mx-auto space-y-8">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-center justify-between">
|
||||
<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 v-for="[groupe, label] in groupes" :key="groupe" class="mb-6">
|
||||
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">{{ label }}</h2>
|
||||
<div v-if="!byStatut(groupe).length" class="text-text-muted text-xs pl-2 mb-2">—</div>
|
||||
<div v-for="t in byStatut(groupe)" :key="t.id"
|
||||
class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard">
|
||||
<span :class="{
|
||||
'text-red': t.priorite === 'haute',
|
||||
'text-yellow': t.priorite === 'normale',
|
||||
'text-text-muted': t.priorite === 'basse'
|
||||
}">●</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-text text-sm">{{ t.titre }}</div>
|
||||
<div v-if="t.description" class="text-text-muted text-xs">{{ t.description }}</div>
|
||||
<div v-if="t.echeance && t.statut !== 'template'" class="text-text-muted text-xs">📅 {{ fmtDate(t.echeance) }}</div>
|
||||
<div v-if="t.frequence_jours != null && t.frequence_jours > 0" class="text-text-muted text-xs">
|
||||
🔁 Tous les {{ t.frequence_jours }} jours
|
||||
<!-- Section "Créer rapidement" -->
|
||||
<section class="bg-bg-soft/20 rounded-xl border border-bg-soft overflow-hidden">
|
||||
<button
|
||||
class="w-full px-5 py-3 flex items-center justify-between text-left hover:bg-bg-soft/30 transition-colors"
|
||||
@click="showQuickSection = !showQuickSection"
|
||||
>
|
||||
<span class="text-text font-bold text-sm flex items-center gap-2">
|
||||
<span class="text-yellow">⚡</span> Créer rapidement
|
||||
</span>
|
||||
<span class="text-text-muted text-xs flex items-center gap-2">
|
||||
<span>{{ totalTemplates }} template{{ totalTemplates > 1 ? 's' : '' }} disponible{{ totalTemplates > 1 ? 's' : '' }}</span>
|
||||
<span :class="['transition-transform inline-block', showQuickSection ? 'rotate-180' : '']">▾</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<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 v-if="t.planting_id && t.statut !== 'template'" class="text-text-muted text-xs">🌱 Plantation #{{ t.planting_id }}</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"
|
||||
@click="store.updateStatut(t.id!, 'en_cours')">→ En cours</button>
|
||||
<button v-if="t.statut === 'en_cours'" class="text-xs text-green hover:underline"
|
||||
@click="store.updateStatut(t.id!, 'fait')">✓ Fait</button>
|
||||
<button
|
||||
v-if="t.statut === 'template'"
|
||||
@click="startEdit(t)"
|
||||
class="text-xs text-yellow hover:underline ml-2"
|
||||
>
|
||||
Édit.
|
||||
</button>
|
||||
<button class="text-xs text-text-muted hover:text-red ml-1" @click="store.remove(t.id!)">✕</button>
|
||||
|
||||
<!-- Tâches courantes prédéfinies -->
|
||||
<div class="p-4 space-y-3">
|
||||
<h3 class="text-text-muted text-[10px] font-bold uppercase tracking-widest">Tâches courantes</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="qt in quickTemplatesFiltered" :key="qt.titre"
|
||||
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]"
|
||||
@click="openScheduleQuick(qt)"
|
||||
>
|
||||
<span>{{ qt.icone }}</span>
|
||||
<span>{{ qt.titre }}</span>
|
||||
</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>
|
||||
|
||||
<!-- 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 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">
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Titre *</label>
|
||||
@@ -54,93 +160,328 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Description</label>
|
||||
<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" />
|
||||
<textarea v-model="form.description" rows="1"
|
||||
@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 class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">Priorité</label>
|
||||
<select v-model="form.priorite" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
|
||||
<option value="basse">Basse</option>
|
||||
<option value="normale">Normale</option>
|
||||
<option value="haute">Haute</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-text-muted text-xs block mb-1">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>
|
||||
<label class="text-text-muted text-xs block mb-1">Priorité</label>
|
||||
<select v-model="form.priorite" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
|
||||
<option value="basse">Basse</option>
|
||||
<option value="normale">Normale</option>
|
||||
<option value="haute">Haute</option>
|
||||
</select>
|
||||
</div>
|
||||
<div 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" />
|
||||
Répétition
|
||||
Répétition périodique
|
||||
</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 v-if="form.repetition">
|
||||
<label class="text-text-muted text-xs block mb-1">Fréquence (jours)</label>
|
||||
<input
|
||||
v-model.number="form.frequence_jours"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
placeholder="Ex: 7"
|
||||
class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none"
|
||||
/>
|
||||
<div v-if="form.repetition" class="flex gap-2 items-center">
|
||||
<input v-model.number="form.freq_nb" type="number" min="1" max="99" required placeholder="1"
|
||||
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" />
|
||||
<select v-model="form.freq_unite" class="flex-1 bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
|
||||
<option value="jours">Jour(s)</option>
|
||||
<option value="semaines">Semaine(s)</option>
|
||||
<option value="mois">Mois</option>
|
||||
</select>
|
||||
<span class="text-text-muted text-xs whitespace-nowrap">= {{ formFreqEnJours }} j</span>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">
|
||||
{{ editId ? 'Enregistrer' : 'Créer le template' }}
|
||||
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold" :disabled="submitting">
|
||||
{{ submitting ? 'Enregistrement…' : (editId ? 'Enregistrer' : 'Créer le template') }}
|
||||
</button>
|
||||
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="closeForm">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
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 { Planting } from '@/api/plantings'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const store = useTasksStore()
|
||||
const showForm = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
interface QuickTemplate {
|
||||
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({
|
||||
titre: '',
|
||||
description: '',
|
||||
priorite: 'normale',
|
||||
statut: 'template',
|
||||
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'],
|
||||
['en_cours', 'En cours'],
|
||||
['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)
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
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() {
|
||||
Object.assign(form, {
|
||||
titre: '',
|
||||
description: '',
|
||||
priorite: 'normale',
|
||||
statut: 'template',
|
||||
repetition: false,
|
||||
frequence_jours: undefined,
|
||||
titre: '', description: '', priorite: 'normale',
|
||||
repetition: false, freq_nb: 1, freq_unite: 'semaines',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,13 +493,19 @@ function openCreateTemplate() {
|
||||
|
||||
function startEdit(t: Task) {
|
||||
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, {
|
||||
titre: t.titre,
|
||||
description: t.description || '',
|
||||
priorite: t.priorite,
|
||||
statut: t.statut || 'template',
|
||||
repetition: Boolean(t.recurrence || t.frequence_jours),
|
||||
frequence_jours: t.frequence_jours ?? undefined,
|
||||
freq_nb: freq_nb || 1,
|
||||
freq_unite,
|
||||
})
|
||||
showForm.value = true
|
||||
}
|
||||
@@ -168,25 +515,130 @@ function closeForm() {
|
||||
editId.value = null
|
||||
}
|
||||
|
||||
onMounted(() => store.fetchAll())
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
const freqJours = form.repetition ? formFreqEnJours.value : null
|
||||
const payload = {
|
||||
titre: form.titre,
|
||||
description: form.description,
|
||||
description: form.description || undefined,
|
||||
priorite: form.priorite,
|
||||
statut: 'template',
|
||||
recurrence: form.repetition ? 'jours' : null,
|
||||
frequence_jours: form.repetition ? (form.frequence_jours ?? 7) : null,
|
||||
echeance: undefined,
|
||||
planting_id: undefined,
|
||||
frequence_jours: freqJours,
|
||||
}
|
||||
if (editId.value) {
|
||||
await store.update(editId.value, payload)
|
||||
} else {
|
||||
await store.create(payload)
|
||||
try {
|
||||
if (editId.value) {
|
||||
await store.update(editId.value, 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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||||
"skipLibCheck": true,
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
|
||||
@@ -1,9 +1,84 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
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: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user