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:
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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('- ')">• 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">—</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 = ``
|
||||||
|
|
||||||
|
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, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.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(/^> (.+)$/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);
|
||||||
|
|||||||
@@ -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);">← {{ slug }}</NuxtLink>
|
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user