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 = ``
+
+ 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, '')
- .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, '
')
+ 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(/((?:- .*<\/li>\n?)+)/g, '')
+ html = html.replace(/((?:
- .*<\/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)
+
+
+
+ | Identifiant |
+ Statut |
+ Volume (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
}
}
+
+