From 2af95ebcf16da7fadac0a914aa7e99a7c4fb0cbe Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 21 Feb 2026 18:05:18 +0100 Subject: [PATCH] Add markdown editor toolbar and data year display on import page CMS editor: formatting toolbar with H1-H3, bold, italic, strikethrough, links, images, lists, blockquotes, code blocks, horizontal rules. Keyboard shortcuts (Ctrl+B/I/D/K). Improved markdown preview rendering. Import page: shows current data summary with year badge, stats grid, last import date. Year input for new imports. Preview with sample table. Backend: added data_year and data_imported_at fields to TariffParams, returned in stats endpoint. Import sets data_imported_at automatically. Seed sets data_year=2018. Co-Authored-By: Claude Opus 4.6 --- backend/app/models/models.py | 2 + backend/app/routers/households.py | 27 +- backend/app/schemas/schemas.py | 5 + backend/seed.py | 3 + .../pages/admin/communes/[slug]/content.vue | 312 +++++++++++++++++- .../pages/admin/communes/[slug]/import.vue | 244 +++++++++++++- 6 files changed, 564 insertions(+), 29 deletions(-) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index ee6d48c..b9b9438 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -47,6 +47,8 @@ class TariffParams(Base): recettes = Column(Float, default=75000.0) pmax = Column(Float, default=20.0) vmax = Column(Float, default=2100.0) + data_year = Column(Integer, nullable=True) + data_imported_at = Column(DateTime, nullable=True) commune = relationship("Commune", back_populates="tariff_params") diff --git a/backend/app/routers/households.py b/backend/app/routers/households.py index c597e05..0c3d081 100644 --- a/backend/app/routers/households.py +++ b/backend/app/routers/households.py @@ -1,3 +1,5 @@ +from datetime import datetime + from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -6,7 +8,7 @@ import io import numpy as np from app.database import get_db -from app.models import Commune, Household, AdminUser +from app.models import Commune, Household, TariffParams, AdminUser from app.schemas import HouseholdOut, HouseholdStats, ImportPreview, ImportResult from app.services.auth_service import get_current_admin from app.services.import_service import parse_import_file, import_households, generate_template_csv @@ -31,6 +33,12 @@ async def household_stats(slug: str, db: AsyncSession = Depends(get_db)): if not commune: raise HTTPException(status_code=404, detail="Commune introuvable") + # Load tariff params for data_year + params_result = await db.execute( + select(TariffParams).where(TariffParams.commune_id == commune.id) + ) + params = params_result.scalar_one_or_none() + hh_result = await db.execute( select(Household).where(Household.commune_id == commune.id) ) @@ -40,6 +48,8 @@ async def household_stats(slug: str, db: AsyncSession = Depends(get_db)): return HouseholdStats( total=0, rs_count=0, rp_count=0, pro_count=0, total_volume=0, avg_volume=0, median_volume=0, voted_count=0, + data_year=params.data_year if params else None, + data_imported_at=params.data_imported_at if params else None, ) volumes = [h.volume_m3 for h in households] @@ -52,6 +62,8 @@ async def household_stats(slug: str, db: AsyncSession = Depends(get_db)): avg_volume=float(np.mean(volumes)), median_volume=float(np.median(volumes)), voted_count=sum(1 for h in households if h.has_voted), + data_year=params.data_year if params else None, + data_imported_at=params.data_imported_at if params else None, ) @@ -82,6 +94,7 @@ async def preview_import( async def do_import( slug: str, file: UploadFile = File(...), + data_year: int | None = None, db: AsyncSession = Depends(get_db), admin: AdminUser = Depends(get_current_admin), ): @@ -97,6 +110,18 @@ async def do_import( raise HTTPException(status_code=400, detail={"errors": parse_errors}) created, import_errors = await import_households(db, commune.id, df) + + # Update data_imported_at and optional data_year on tariff params + params_result = await db.execute( + select(TariffParams).where(TariffParams.commune_id == commune.id) + ) + params = params_result.scalar_one_or_none() + if params: + params.data_imported_at = datetime.utcnow() + if data_year is not None: + params.data_year = data_year + await db.commit() + return ImportResult(created=created, errors=import_errors) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 33c0c1e..cce8571 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -56,6 +56,7 @@ class TariffParamsUpdate(BaseModel): recettes: float | None = None pmax: float | None = None vmax: float | None = None + data_year: int | None = None class TariffParamsOut(BaseModel): @@ -64,6 +65,8 @@ class TariffParamsOut(BaseModel): recettes: float pmax: float vmax: float + data_year: int | None = None + data_imported_at: datetime | None = None model_config = {"from_attributes": True} @@ -91,6 +94,8 @@ class HouseholdStats(BaseModel): avg_volume: float median_volume: float voted_count: int + data_year: int | None = None + data_imported_at: datetime | None = None class ImportPreview(BaseModel): diff --git a/backend/seed.py b/backend/seed.py index 038bae7..c52a6be 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -3,6 +3,7 @@ import asyncio import sys import os +from datetime import datetime sys.path.insert(0, os.path.dirname(__file__)) @@ -44,6 +45,8 @@ async def seed(): recettes=75000, pmax=20, vmax=2100, + data_year=2018, + data_imported_at=datetime.utcnow(), ) db.add(params) diff --git a/frontend/app/pages/admin/communes/[slug]/content.vue b/frontend/app/pages/admin/communes/[slug]/content.vue index 34c9fac..2cfed39 100644 --- a/frontend/app/pages/admin/communes/[slug]/content.vue +++ b/frontend/app/pages/admin/communes/[slug]/content.vue @@ -81,11 +81,46 @@
+ + +
+
+ + + +
+
+
+ + + +
+
+
+ + +
+
+
+ + + +
+
+
+ + + +
+
+
@@ -142,6 +177,7 @@ const editing = ref(null) const previewMode = ref(false) const saving = ref(false) const deleting = ref(false) +const textareaRef = ref(null) onMounted(async () => { await loadPages() @@ -217,23 +253,201 @@ async function doDelete() { } } +// ── Markdown toolbar helpers ── + +function getTextarea(): HTMLTextAreaElement | null { + return textareaRef.value +} + +function wrapSelection(marker: string) { + const ta = getTextarea() + if (!ta || !editing.value) return + const start = ta.selectionStart + const end = ta.selectionEnd + const text = editing.value.body_markdown + const selected = text.substring(start, end) + const placeholder = selected || 'texte' + const replacement = `${marker}${placeholder}${marker}` + + editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(end) + + nextTick(() => { + ta.focus() + if (selected) { + ta.selectionStart = start + ta.selectionEnd = start + replacement.length + } else { + ta.selectionStart = start + marker.length + ta.selectionEnd = start + marker.length + placeholder.length + } + }) +} + +function insertBlock(prefix: string) { + const ta = getTextarea() + if (!ta || !editing.value) return + const start = ta.selectionStart + const text = editing.value.body_markdown + + // Find the start of the current line + const lineStart = text.lastIndexOf('\n', start - 1) + 1 + const before = text.substring(0, lineStart) + const after = text.substring(lineStart) + + editing.value.body_markdown = before + prefix + after + + nextTick(() => { + ta.focus() + ta.selectionStart = ta.selectionEnd = lineStart + prefix.length + }) +} + +function insertLink() { + const ta = getTextarea() + if (!ta || !editing.value) return + const start = ta.selectionStart + const end = ta.selectionEnd + const text = editing.value.body_markdown + const selected = text.substring(start, end) + const label = selected || 'titre du lien' + const replacement = `[${label}](url)` + + editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(end) + + nextTick(() => { + ta.focus() + // Select "url" for quick editing + const urlStart = start + label.length + 3 + ta.selectionStart = urlStart + ta.selectionEnd = urlStart + 3 + }) +} + +function insertImage() { + const ta = getTextarea() + if (!ta || !editing.value) return + const start = ta.selectionStart + const end = ta.selectionEnd + const text = editing.value.body_markdown + const selected = text.substring(start, end) + const alt = selected || 'description' + const replacement = `![${alt}](url)` + + editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(end) + + nextTick(() => { + ta.focus() + const urlStart = start + alt.length + 4 + ta.selectionStart = urlStart + ta.selectionEnd = urlStart + 3 + }) +} + +function insertCodeBlock() { + const ta = getTextarea() + if (!ta || !editing.value) return + const start = ta.selectionStart + const end = ta.selectionEnd + const text = editing.value.body_markdown + const selected = text.substring(start, end) + const code = selected || 'code' + const replacement = `\n\`\`\`\n${code}\n\`\`\`\n` + + editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(end) + + nextTick(() => { + ta.focus() + ta.selectionStart = start + 4 + ta.selectionEnd = start + 4 + code.length + }) +} + +function insertHR() { + const ta = getTextarea() + if (!ta || !editing.value) return + const start = ta.selectionStart + const text = editing.value.body_markdown + const replacement = '\n---\n' + + editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(start) + + nextTick(() => { + ta.focus() + ta.selectionStart = ta.selectionEnd = start + replacement.length + }) +} + +function onKeydown(e: KeyboardEvent) { + if (!editing.value) return + // Ctrl+B = bold + if (e.ctrlKey && e.key === 'b') { + e.preventDefault() + wrapSelection('**') + } + // Ctrl+I = italic + if (e.ctrlKey && e.key === 'i') { + e.preventDefault() + wrapSelection('*') + } + // Ctrl+D = strikethrough + if (e.ctrlKey && e.key === 'd') { + e.preventDefault() + wrapSelection('~~') + } + // Ctrl+K = link + if (e.ctrlKey && e.key === 'k') { + e.preventDefault() + insertLink() + } +} + function renderMarkdown(md: string): string { if (!md) return '

Aucun contenu.

' - // Simple markdown rendering (headings, bold, italic, links, paragraphs, lists) - return md + let html = md .replace(/&/g, '&') .replace(//g, '>') - .replace(/^### (.+)$/gm, '

$1

') - .replace(/^## (.+)$/gm, '

$1

') - .replace(/^# (.+)$/gm, '

$1

') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/\[(.+?)\]\((.+?)\)/g, '$1') - .replace(/^- (.+)$/gm, '
  • $1
  • ') - .replace(/(
  • .*<\/li>)/s, '
      $1
    ') - .replace(/\n\n/g, '

    ') - .replace(/^(?!<[hulo])(.+)$/gm, '

    $1

    ') + + // Code blocks (before other transformations) + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => + `
    ${code.trim()}
    `) + + // Headings + html = html.replace(/^### (.+)$/gm, '

    $1

    ') + html = html.replace(/^## (.+)$/gm, '

    $1

    ') + html = html.replace(/^# (.+)$/gm, '

    $1

    ') + + // Horizontal rule + html = html.replace(/^---$/gm, '
    ') + + // Bold, italic, strikethrough, inline code + html = html.replace(/\*\*(.+?)\*\*/g, '$1') + html = html.replace(/\*(.+?)\*/g, '$1') + html = html.replace(/~~(.+?)~~/g, '$1') + html = html.replace(/`([^`]+)`/g, '$1') + + // Images and links + html = html.replace(/!\[(.+?)\]\((.+?)\)/g, '$1') + html = html.replace(/\[(.+?)\]\((.+?)\)/g, '$1') + + // Blockquotes + html = html.replace(/^> (.+)$/gm, '
    $1
    ') + + // Unordered lists + html = html.replace(/^- (.+)$/gm, '
  • $1
  • ') + // Ordered lists + html = html.replace(/^\d+\. (.+)$/gm, '
  • $1
  • ') + + // Wrap consecutive
  • in
      or
        + html = html.replace(/((?:
      1. .*<\/li>\n?)+)/g, '
          $1
        ') + html = html.replace(/((?:
      2. .*<\/li>\n?)+)/g, (_m, items) => + '
          ' + items.replace(/ class="ol"/g, '') + '
        ') + + // Paragraphs (lines not already wrapped) + html = html.replace(/\n\n/g, '

        ') + html = html.replace(/^(?!<[hupodbia\-lr/])(.+)$/gm, '

        $1

        ') + + return html } @@ -259,11 +473,56 @@ function renderMarkdown(md: string): string { background: #eff6ff; } +/* Markdown toolbar */ +.md-toolbar { + display: flex; + flex-wrap: wrap; + gap: 2px; + padding: 0.4rem; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-bottom: none; + border-radius: var(--radius) var(--radius) 0 0; +} + +.md-toolbar-group { + display: flex; + gap: 2px; +} + +.md-toolbar-sep { + width: 1px; + background: var(--color-border); + margin: 0 4px; +} + +.md-btn { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + background: transparent; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer; + color: var(--color-text); + white-space: nowrap; + line-height: 1.4; +} + +.md-btn:hover { + background: var(--color-surface, #e5e7eb); + border-color: var(--color-border); +} + +.md-btn:active { + background: var(--color-border); +} + .content-textarea { font-family: 'Fira Code', 'Consolas', monospace; font-size: 0.85rem; line-height: 1.6; resize: vertical; + border-radius: 0 0 var(--radius) var(--radius); } .preview-box { @@ -279,8 +538,33 @@ function renderMarkdown(md: string): string { .preview-box h2 { font-size: 1.25rem; margin: 0.75rem 0 0.5rem; } .preview-box h3 { font-size: 1.1rem; margin: 0.5rem 0 0.25rem; } .preview-box p { margin: 0.5rem 0; } -.preview-box ul { margin: 0.5rem 0; padding-left: 1.5rem; } +.preview-box ul, .preview-box ol { margin: 0.5rem 0; padding-left: 1.5rem; } .preview-box a { color: var(--color-primary); } +.preview-box hr { border: none; border-top: 1px solid var(--color-border); margin: 1rem 0; } +.preview-box blockquote { + border-left: 3px solid var(--color-primary); + padding: 0.25rem 0.75rem; + margin: 0.5rem 0; + color: var(--color-text-muted); + background: rgba(0,0,0,0.02); +} +.preview-box pre { + background: #1e293b; + color: #e2e8f0; + padding: 0.75rem 1rem; + border-radius: var(--radius); + overflow-x: auto; + font-size: 0.85rem; + margin: 0.5rem 0; +} +.preview-box code.inline { + background: rgba(0,0,0,0.06); + padding: 0.1rem 0.35rem; + border-radius: 3px; + font-size: 0.85em; +} +.preview-box img { border-radius: var(--radius); margin: 0.5rem 0; } +.preview-box del { text-decoration: line-through; color: var(--color-text-muted); } .btn-danger { background: var(--color-danger); diff --git a/frontend/app/pages/admin/communes/[slug]/import.vue b/frontend/app/pages/admin/communes/[slug]/import.vue index 1e13f32..af4a923 100644 --- a/frontend/app/pages/admin/communes/[slug]/import.vue +++ b/frontend/app/pages/admin/communes/[slug]/import.vue @@ -2,19 +2,96 @@
        -
        -

        - Importez un fichier CSV ou XLSX avec les colonnes : + +

        +
        +

        Donnees actuelles

        +
        + Donnees {{ stats.data_year }} +
        +
        + +
        +
        +
        + +
        +
        +
        +
        {{ stats.total }}
        +
        Foyers
        +
        +
        +
        {{ stats.rp_count }}
        +
        Residences principales
        +
        +
        +
        {{ stats.rs_count }}
        +
        Residences secondaires
        +
        +
        +
        {{ stats.pro_count }}
        +
        Professionnels
        +
        +
        +
        {{ formatVolume(stats.total_volume) }}
        +
        Volume total (m3)
        +
        +
        +
        {{ formatVolume(stats.avg_volume) }}
        +
        Volume moyen (m3)
        +
        +
        +
        {{ formatVolume(stats.median_volume) }}
        +
        Volume median (m3)
        +
        +
        +
        {{ stats.voted_count }}
        +
        Ont vote
        +
        +
        + +
        + Derniere importation : {{ new Date(stats.data_imported_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }) }} +
        +
        + +
        + Aucune donnee importee. Utilisez le formulaire ci-dessous pour importer les foyers. +
        +
        + + +
        +

        + {{ stats && stats.total > 0 ? 'Mettre a jour les donnees' : 'Importer les donnees' }} +

        + +

        + Fichier CSV ou XLSX avec les colonnes : identifier, status, volume_m3, price_eur

        - - Télécharger le template + + Telecharger le template CSV +
        + + +
        +
        @@ -29,30 +106,55 @@
    - {{ preview.valid_rows }} foyers valides prêts à importer. + {{ preview.valid_rows }} foyers valides prets a importer. +
    +
    + Apercu ({{ Math.min(5, preview.valid_rows) }} premiers) + + + + + + + + + + + + + + + + + +
    IdentifiantStatutVolume (m3)Prix (EUR)
    {{ row.identifier }}{{ row.status }}{{ row.volume_m3 }}{{ row.price_eur ?? '-' }}
    +
    +
    - +
    - {{ result.created }} foyers importés. - ({{ result.errors.length }} avertissements) + {{ result.created }} foyers importes. + ({{ result.errors.length }} avertissements)
    +
    {{ importError }}
    +
    @@ -68,22 +170,65 @@ const api = useApi() const slug = route.params.slug as string const apiBase = config.public.apiBase as string +interface Stats { + total: number + rs_count: number + rp_count: number + pro_count: number + total_volume: number + avg_volume: number + median_volume: number + voted_count: number + data_year: number | null + data_imported_at: string | null +} + +const stats = ref(null) +const statsLoading = ref(true) +const currentYear = new Date().getFullYear() +const dataYear = ref(null) + const file = ref(null) const preview = ref(null) const result = ref(null) const previewLoading = ref(false) const importLoading = ref(false) +const importError = ref('') + +onMounted(async () => { + await loadStats() +}) + +async function loadStats() { + statsLoading.value = true + try { + stats.value = await api.get(`/communes/${slug}/households/stats`) + if (stats.value?.data_year) { + dataYear.value = stats.value.data_year + } + } catch { + // Stats not available yet + } finally { + statsLoading.value = false + } +} + +function formatVolume(v: number): string { + return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) +} function onFileChange(e: Event) { const input = e.target as HTMLInputElement file.value = input.files?.[0] || null preview.value = null result.value = null + importError.value = '' } async function doPreview() { if (!file.value) return previewLoading.value = true + importError.value = '' const fd = new FormData() fd.append('file', file.value) try { @@ -98,14 +243,85 @@ async function doPreview() { async function doImport() { if (!file.value) return importLoading.value = true + importError.value = '' const fd = new FormData() fd.append('file', file.value) + // Append data_year as query param + const yearParam = dataYear.value ? `?data_year=${dataYear.value}` : '' try { - result.value = await api.post(`/communes/${slug}/households/import`, fd) + result.value = await api.post(`/communes/${slug}/households/import${yearParam}`, fd) + // Reload stats to reflect new data + await loadStats() } catch (e: any) { - result.value = { created: 0, errors: [e.message] } + importError.value = e.message + result.value = null } finally { importLoading.value = false } } + +