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 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-21 18:05:18 +01:00
parent 1365f4c86c
commit 2af95ebcf1
6 changed files with 564 additions and 29 deletions

View File

@@ -47,6 +47,8 @@ class TariffParams(Base):
recettes = Column(Float, default=75000.0) recettes = Column(Float, default=75000.0)
pmax = Column(Float, default=20.0) pmax = Column(Float, default=20.0)
vmax = Column(Float, default=2100.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") commune = relationship("Commune", back_populates="tariff_params")

View File

@@ -1,3 +1,5 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,7 +8,7 @@ import io
import numpy as np import numpy as np
from app.database import get_db 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.schemas import HouseholdOut, HouseholdStats, ImportPreview, ImportResult
from app.services.auth_service import get_current_admin from app.services.auth_service import get_current_admin
from app.services.import_service import parse_import_file, import_households, generate_template_csv 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: if not commune:
raise HTTPException(status_code=404, detail="Commune introuvable") 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( hh_result = await db.execute(
select(Household).where(Household.commune_id == commune.id) 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( return HouseholdStats(
total=0, rs_count=0, rp_count=0, pro_count=0, total=0, rs_count=0, rp_count=0, pro_count=0,
total_volume=0, avg_volume=0, median_volume=0, voted_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] 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)), avg_volume=float(np.mean(volumes)),
median_volume=float(np.median(volumes)), median_volume=float(np.median(volumes)),
voted_count=sum(1 for h in households if h.has_voted), 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( async def do_import(
slug: str, slug: str,
file: UploadFile = File(...), file: UploadFile = File(...),
data_year: int | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(get_current_admin), admin: AdminUser = Depends(get_current_admin),
): ):
@@ -97,6 +110,18 @@ async def do_import(
raise HTTPException(status_code=400, detail={"errors": parse_errors}) raise HTTPException(status_code=400, detail={"errors": parse_errors})
created, import_errors = await import_households(db, commune.id, df) 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) return ImportResult(created=created, errors=import_errors)

View File

@@ -56,6 +56,7 @@ class TariffParamsUpdate(BaseModel):
recettes: float | None = None recettes: float | None = None
pmax: float | None = None pmax: float | None = None
vmax: float | None = None vmax: float | None = None
data_year: int | None = None
class TariffParamsOut(BaseModel): class TariffParamsOut(BaseModel):
@@ -64,6 +65,8 @@ class TariffParamsOut(BaseModel):
recettes: float recettes: float
pmax: float pmax: float
vmax: float vmax: float
data_year: int | None = None
data_imported_at: datetime | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -91,6 +94,8 @@ class HouseholdStats(BaseModel):
avg_volume: float avg_volume: float
median_volume: float median_volume: float
voted_count: int voted_count: int
data_year: int | None = None
data_imported_at: datetime | None = None
class ImportPreview(BaseModel): class ImportPreview(BaseModel):

View File

@@ -3,6 +3,7 @@
import asyncio import asyncio
import sys import sys
import os import os
from datetime import datetime
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
@@ -44,6 +45,8 @@ async def seed():
recettes=75000, recettes=75000,
pmax=20, pmax=20,
vmax=2100, vmax=2100,
data_year=2018,
data_imported_at=datetime.utcnow(),
) )
db.add(params) db.add(params)

View File

@@ -81,11 +81,46 @@
<div v-if="!previewMode" class="form-group"> <div v-if="!previewMode" class="form-group">
<label>Contenu (Markdown)</label> <label>Contenu (Markdown)</label>
<!-- Toolbar -->
<div class="md-toolbar">
<div class="md-toolbar-group">
<button type="button" class="md-btn" title="Titre 1" @click="insertBlock('# ')">H1</button>
<button type="button" class="md-btn" title="Titre 2" @click="insertBlock('## ')">H2</button>
<button type="button" class="md-btn" title="Titre 3" @click="insertBlock('### ')">H3</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-btn" title="Gras (Ctrl+B)" @click="wrapSelection('**')"><b>G</b></button>
<button type="button" class="md-btn" title="Italique (Ctrl+I)" @click="wrapSelection('*')"><i>I</i></button>
<button type="button" class="md-btn" title="Barre (Ctrl+D)" @click="wrapSelection('~~')"><s>S</s></button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-btn" title="Lien" @click="insertLink">Lien</button>
<button type="button" class="md-btn" title="Image" @click="insertImage">Image</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-btn" title="Liste a puces" @click="insertBlock('- ')">&#8226; Liste</button>
<button type="button" class="md-btn" title="Liste numerotee" @click="insertBlock('1. ')">1. Liste</button>
<button type="button" class="md-btn" title="Citation" @click="insertBlock('> ')">Citation</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-btn" title="Code inline" @click="wrapSelection('`')">Code</button>
<button type="button" class="md-btn" title="Bloc de code" @click="insertCodeBlock">```</button>
<button type="button" class="md-btn" title="Ligne horizontale" @click="insertHR">&#8212;</button>
</div>
</div>
<textarea <textarea
ref="textareaRef"
v-model="editing.body_markdown" v-model="editing.body_markdown"
class="form-input content-textarea" class="form-input content-textarea"
rows="15" rows="20"
placeholder="Redigez votre contenu en Markdown..." placeholder="Redigez votre contenu en Markdown..."
@keydown="onKeydown"
></textarea> ></textarea>
</div> </div>
@@ -142,6 +177,7 @@ const editing = ref<ContentPage | null>(null)
const previewMode = ref(false) const previewMode = ref(false)
const saving = ref(false) const saving = ref(false)
const deleting = ref(false) const deleting = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
onMounted(async () => { onMounted(async () => {
await loadPages() 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 { function renderMarkdown(md: string): string {
if (!md) return '<p style="color: var(--color-text-muted);">Aucun contenu.</p>' if (!md) return '<p style="color: var(--color-text-muted);">Aucun contenu.</p>'
// Simple markdown rendering (headings, bold, italic, links, paragraphs, lists) let html = md
return md
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>') // Code blocks (before other transformations)
.replace(/^# (.+)$/gm, '<h1>$1</h1>') html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') `<pre><code>${code.trim()}</code></pre>`)
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>') // Headings
.replace(/^- (.+)$/gm, '<li>$1</li>') html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>') html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/\n\n/g, '</p><p>') html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
// Horizontal rule
html = html.replace(/^---$/gm, '<hr />')
// Bold, italic, strikethrough, inline code
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>')
html = html.replace(/`([^`]+)`/g, '<code class="inline">$1</code>')
// Images and links
html = html.replace(/!\[(.+?)\]\((.+?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;" />')
html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank">$1</a>')
// Blockquotes
html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>')
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
// Ordered lists
html = html.replace(/^\d+\. (.+)$/gm, '<li class="ol">$1</li>')
// Wrap consecutive <li> in <ul> or <ol>
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
html = html.replace(/((?:<li class="ol">.*<\/li>\n?)+)/g, (_m, items) =>
'<ol>' + items.replace(/ class="ol"/g, '') + '</ol>')
// Paragraphs (lines not already wrapped)
html = html.replace(/\n\n/g, '</p><p>')
html = html.replace(/^(?!<[hupodbia\-lr/])(.+)$/gm, '<p>$1</p>')
return html
} }
</script> </script>
@@ -259,11 +473,56 @@ function renderMarkdown(md: string): string {
background: #eff6ff; 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 { .content-textarea {
font-family: 'Fira Code', 'Consolas', monospace; font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.6; line-height: 1.6;
resize: vertical; resize: vertical;
border-radius: 0 0 var(--radius) var(--radius);
} }
.preview-box { .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 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 h3 { font-size: 1.1rem; margin: 0.5rem 0 0.25rem; }
.preview-box p { margin: 0.5rem 0; } .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 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 { .btn-danger {
background: var(--color-danger); background: var(--color-danger);

View File

@@ -2,19 +2,96 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink> <NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Import des foyers</h1> <h1>Donnees des foyers</h1>
</div> </div>
<div class="card" style="max-width: 700px;"> <!-- Current data summary -->
<p style="margin-bottom: 1rem;"> <div class="card" style="margin-bottom: 1.5rem;">
Importez un fichier CSV ou XLSX avec les colonnes : <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>Donnees actuelles</h3>
<div v-if="stats && stats.data_year" class="year-badge">
Donnees {{ stats.data_year }}
</div>
</div>
<div v-if="statsLoading" style="text-align: center; padding: 1rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
<div v-else-if="stats && stats.total > 0">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">Foyers</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.rp_count }}</div>
<div class="stat-label">Residences principales</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.rs_count }}</div>
<div class="stat-label">Residences secondaires</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.pro_count }}</div>
<div class="stat-label">Professionnels</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ formatVolume(stats.total_volume) }}</div>
<div class="stat-label">Volume total (m3)</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ formatVolume(stats.avg_volume) }}</div>
<div class="stat-label">Volume moyen (m3)</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ formatVolume(stats.median_volume) }}</div>
<div class="stat-label">Volume median (m3)</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.voted_count }}</div>
<div class="stat-label">Ont vote</div>
</div>
</div>
<div v-if="stats.data_imported_at" style="margin-top: 0.75rem; font-size: 0.8rem; color: var(--color-text-muted);">
Derniere importation : {{ new Date(stats.data_imported_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }) }}
</div>
</div>
<div v-else class="alert alert-info" style="margin: 0;">
Aucune donnee importee. Utilisez le formulaire ci-dessous pour importer les foyers.
</div>
</div>
<!-- Import / Update -->
<div class="card">
<h3 style="margin-bottom: 1rem;">
{{ stats && stats.total > 0 ? 'Mettre a jour les donnees' : 'Importer les donnees' }}
</h3>
<p style="margin-bottom: 1rem; color: var(--color-text-muted);">
Fichier CSV ou XLSX avec les colonnes :
<code>identifier, status, volume_m3, price_eur</code> <code>identifier, status, volume_m3, price_eur</code>
</p> </p>
<a :href="`${apiBase}/communes/${slug}/households/template`" class="btn btn-secondary" style="margin-bottom: 1rem;"> <a :href="`${apiBase}/communes/${slug}/households/template`" class="btn btn-secondary" style="margin-bottom: 1rem; display: inline-block;">
Télécharger le template Telecharger le template CSV
</a> </a>
<div class="form-group">
<label>Annee des donnees</label>
<input
v-model.number="dataYear"
type="number"
class="form-input"
style="max-width: 150px;"
:placeholder="currentYear.toString()"
min="2000"
:max="currentYear"
/>
</div>
<div class="form-group"> <div class="form-group">
<label>Fichier (CSV ou XLSX)</label> <label>Fichier (CSV ou XLSX)</label>
<input type="file" accept=".csv,.xlsx,.xls" @change="onFileChange" class="form-input" /> <input type="file" accept=".csv,.xlsx,.xls" @change="onFileChange" class="form-input" />
@@ -29,30 +106,55 @@
</ul> </ul>
</div> </div>
<div v-else class="alert alert-success"> <div v-else class="alert alert-success">
{{ preview.valid_rows }} foyers valides prêts à importer. {{ preview.valid_rows }} foyers valides prets a importer.
<div v-if="preview.sample && preview.sample.length" style="margin-top: 0.5rem;">
<details>
<summary style="cursor: pointer; font-size: 0.85rem;">Apercu ({{ Math.min(5, preview.valid_rows) }} premiers)</summary>
<table class="sample-table">
<thead>
<tr>
<th>Identifiant</th>
<th>Statut</th>
<th>Volume (m3)</th>
<th>Prix (EUR)</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in preview.sample" :key="i">
<td>{{ row.identifier }}</td>
<td>{{ row.status }}</td>
<td>{{ row.volume_m3 }}</td>
<td>{{ row.price_eur ?? '-' }}</td>
</tr>
</tbody>
</table>
</details>
</div>
</div> </div>
</div> </div>
<!-- Result --> <!-- Import result -->
<div v-if="result" class="alert alert-success"> <div v-if="result" class="alert alert-success">
{{ result.created }} foyers importés. {{ result.created }} foyers importes.
<span v-if="result.errors.length"> ({{ result.errors.length }} avertissements)</span> <span v-if="result.errors && result.errors.length"> ({{ result.errors.length }} avertissements)</span>
</div> </div>
<div v-if="importError" class="alert alert-error">{{ importError }}</div>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<button <button
class="btn btn-secondary" class="btn btn-secondary"
:disabled="!file || previewLoading" :disabled="!file || previewLoading"
@click="doPreview" @click="doPreview"
> >
Vérifier {{ previewLoading ? 'Verification...' : 'Verifier' }}
</button> </button>
<button <button
class="btn btn-primary" class="btn btn-primary"
:disabled="!file || importLoading || (preview && preview.errors.length > 0)" :disabled="!file || importLoading || (preview && preview.errors.length > 0)"
@click="doImport" @click="doImport"
> >
Importer {{ importLoading ? 'Importation...' : 'Importer' }}
</button> </button>
</div> </div>
</div> </div>
@@ -68,22 +170,65 @@ const api = useApi()
const slug = route.params.slug as string const slug = route.params.slug as string
const apiBase = config.public.apiBase 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<Stats | null>(null)
const statsLoading = ref(true)
const currentYear = new Date().getFullYear()
const dataYear = ref<number | null>(null)
const file = ref<File | null>(null) const file = ref<File | null>(null)
const preview = ref<any>(null) const preview = ref<any>(null)
const result = ref<any>(null) const result = ref<any>(null)
const previewLoading = ref(false) const previewLoading = ref(false)
const importLoading = 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<Stats>(`/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) { function onFileChange(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
file.value = input.files?.[0] || null file.value = input.files?.[0] || null
preview.value = null preview.value = null
result.value = null result.value = null
importError.value = ''
} }
async function doPreview() { async function doPreview() {
if (!file.value) return if (!file.value) return
previewLoading.value = true previewLoading.value = true
importError.value = ''
const fd = new FormData() const fd = new FormData()
fd.append('file', file.value) fd.append('file', file.value)
try { try {
@@ -98,14 +243,85 @@ async function doPreview() {
async function doImport() { async function doImport() {
if (!file.value) return if (!file.value) return
importLoading.value = true importLoading.value = true
importError.value = ''
const fd = new FormData() const fd = new FormData()
fd.append('file', file.value) fd.append('file', file.value)
// Append data_year as query param
const yearParam = dataYear.value ? `?data_year=${dataYear.value}` : ''
try { 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) { } catch (e: any) {
result.value = { created: 0, errors: [e.message] } importError.value = e.message
result.value = null
} finally { } finally {
importLoading.value = false importLoading.value = false
} }
} }
</script> </script>
<style scoped>
.year-badge {
display: inline-block;
padding: 0.35rem 0.75rem;
background: var(--color-primary);
color: white;
border-radius: 999px;
font-weight: 600;
font-size: 0.95rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.stat-card {
padding: 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
text-align: center;
}
.stat-value {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.2rem;
}
.sample-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.sample-table th,
.sample-table td {
padding: 0.35rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.sample-table th {
font-weight: 600;
background: var(--color-bg);
}
.alert-success {
background: #dcfce7;
color: #166534;
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
</style>