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>
328 lines
9.6 KiB
Vue
328 lines
9.6 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ slug }}</NuxtLink>
|
|
<h1>Donnees des foyers</h1>
|
|
</div>
|
|
|
|
<!-- Current data summary -->
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
<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>
|
|
</p>
|
|
|
|
<a :href="`${apiBase}/communes/${slug}/households/template`" class="btn btn-secondary" style="margin-bottom: 1rem; display: inline-block;">
|
|
Telecharger le template CSV
|
|
</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">
|
|
<label>Fichier (CSV ou XLSX)</label>
|
|
<input type="file" accept=".csv,.xlsx,.xls" @change="onFileChange" class="form-input" />
|
|
</div>
|
|
|
|
<!-- Preview -->
|
|
<div v-if="preview" style="margin: 1rem 0;">
|
|
<div v-if="preview.errors.length" class="alert alert-error">
|
|
<strong>Erreurs :</strong>
|
|
<ul style="margin: 0.5rem 0 0 1rem;">
|
|
<li v-for="err in preview.errors" :key="err">{{ err }}</li>
|
|
</ul>
|
|
</div>
|
|
<div v-else class="alert alert-success">
|
|
{{ 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>
|
|
|
|
<!-- Import result -->
|
|
<div v-if="result" class="alert alert-success">
|
|
{{ result.created }} foyers importes.
|
|
<span v-if="result.errors && result.errors.length"> ({{ result.errors.length }} avertissements)</span>
|
|
</div>
|
|
|
|
<div v-if="importError" class="alert alert-error">{{ importError }}</div>
|
|
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
<button
|
|
class="btn btn-secondary"
|
|
:disabled="!file || previewLoading"
|
|
@click="doPreview"
|
|
>
|
|
{{ previewLoading ? 'Verification...' : 'Verifier' }}
|
|
</button>
|
|
<button
|
|
class="btn btn-primary"
|
|
:disabled="!file || importLoading || (preview && preview.errors.length > 0)"
|
|
@click="doImport"
|
|
>
|
|
{{ importLoading ? 'Importation...' : 'Importer' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({ middleware: 'admin' })
|
|
|
|
const route = useRoute()
|
|
const config = useRuntimeConfig()
|
|
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<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 preview = ref<any>(null)
|
|
const result = ref<any>(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<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) {
|
|
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 {
|
|
preview.value = await api.post(`/communes/${slug}/households/import/preview`, fd)
|
|
} catch (e: any) {
|
|
preview.value = { valid_rows: 0, errors: [e.message], sample: [] }
|
|
} finally {
|
|
previewLoading.value = false
|
|
}
|
|
}
|
|
|
|
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${yearParam}`, fd)
|
|
// Reload stats to reflect new data
|
|
await loadStats()
|
|
} catch (e: any) {
|
|
importError.value = e.message
|
|
result.value = null
|
|
} finally {
|
|
importLoading.value = false
|
|
}
|
|
}
|
|
</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>
|