Migrate grateWizard from React/Next.js to native Nuxt integration

- Port all React components to Vue 3 (GwTabs, GwMN, GwCRA, GwCRS,
  GwMap, GwRelations, GwPerimeterList)
- Port hooks to Vue composables (useCesiumProfiles, useSavedPerimeters)
- Copy pure TS services and utils (duniter/, ss58, gratewizard utils)
- Add Leaflet + Geoman + MarkerCluster dependencies
- Serve grateWizard as popup via /gratewizard?popup (layout: false)
  and info page on /gratewizard (with Librodrome layout)
- Remove public/gratewizard-app/ static Next.js export
- Refine UI: compact tabs, buttons, inputs, cards, perimeter list
- Use Ğ1 breve everywhere, French locale for all dates and amounts
- Rename roles: vendeur→offre / acheteur→reçoit le produit ou service
- Rename prix→évaluation in all visible text
- Add calculated result column in CRA and CRS relation tables
- DU/Ğ1 selector uses toggle switch (same as role toggle)
- Auto-scroll to monetary data card on polygon selection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-21 15:33:01 +01:00
parent 524c7a0fc2
commit 2b5543791f
93 changed files with 2186 additions and 585 deletions

View File

@@ -0,0 +1,272 @@
<template>
<div class="flex flex-col gap-5 pt-4">
<!-- Error state -->
<div v-if="error" class="p-8 flex flex-col gap-4">
<p class="gw-text">Erreur : {{ error }}</p>
</div>
<template v-else>
<!-- Currency selector -->
<div class="flex items-center justify-center gap-2">
<span class="gw-text text-xs">DU</span>
<label class="gw-toggle">
<input
type="checkbox"
:checked="unit === 'G1'"
@change="unit = ($event.target as HTMLInputElement).checked ? 'G1' : 'DU'"
/>
<span class="gw-toggle-slider" />
</label>
<span class="gw-text text-xs">&#x11E;1</span>
</div>
<!-- Monetary data card -->
<div ref="topRef" class="card-surface">
<div class="flex flex-col items-center gap-3">
<p class="gw-metric">Masse mon&eacute;taire / Membres</p>
<span v-if="polygon" class="gw-chip">
{{ activePerimeterName ?? 'S\u00e9lection manuelle' }}
</span>
<!-- Loading -->
<div v-if="loading" class="flex flex-col items-center gap-2 py-4">
<div class="gw-spinner" />
<p class="gw-text text-sm">Connexion au r&eacute;seau...</p>
</div>
<!-- Data grid -->
<div v-else-if="monetary" class="grid grid-cols-2 gap-2 w-full max-w-sm">
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
<p class="gw-text">M (masse{{ polygon ? ' locale' : '' }})</p>
<p class="gw-title mt-1">
{{ mnIsLoading ? '...' : formatValue(displayM!, unit, duDaily) }}
</p>
</div>
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
<p class="gw-text">N (membres{{ polygon ? ' locaux' : '' }})</p>
<p class="gw-title mt-1">{{ displayN.toLocaleString('fr-FR') }}</p>
</div>
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
<p class="gw-text">M / N{{ polygon ? ' (local)' : '' }}</p>
<p class="gw-title mt-1">
{{ mnIsLoading ? '...' : formatValue(displayMN!, unit, duDaily) }}
</p>
</div>
<div class="text-center p-2 rounded-md text-xs bg-surface-200">
<p class="gw-text">DU journalier</p>
<p class="gw-title mt-1">{{ fr(duDaily) }} &#x11E;1</p>
</div>
<!-- Extra cells when polygon is active -->
<template v-if="polygon && nLocal > 0 && monetary">
<div class="text-center p-2 rounded-md text-xs bg-surface-200">
<p class="gw-text">M / N (r&eacute;seau)</p>
<p class="gw-title mt-1">{{ formatValue(mnG1, unit, duDaily) }}</p>
</div>
<div class="text-center p-2 rounded-md text-xs bg-accent/10">
<p class="gw-text">Part du r&eacute;seau</p>
<p class="gw-title mt-1">{{ fr(nLocal / monetary.membersCount * 100) }} %</p>
</div>
</template>
<div class="col-span-2 text-center">
<p class="gw-text">
Bloc #{{ monetary.blockNumber }} &mdash;
{{ new Date(monetary.timestamp).toLocaleDateString('fr-FR', { dateStyle: 'long' }) }}
</p>
</div>
</div>
<!-- Clear selection button -->
<div v-if="polygon" class="flex flex-col items-center gap-2">
<button
class="gw-btn gw-btn-danger"
@click="handleClearSelection"
>
P&eacute;rim&egrave;tre monde
</button>
</div>
<!-- Saved perimeters -->
<GratewizardGwPerimeterList
:perimeters="perimeters"
@load="handleLoadPerimeter"
@delete="deletePerimeter"
/>
</div>
</div>
<!-- Map card -->
<div class="card-surface">
<div class="flex flex-col items-center gap-3">
<p class="gw-metric">
Carte des membres g&eacute;olocalis&eacute;s
<span v-if="!geoLoading && v1Pubkeys" class="gw-chip ml-2">
{{ geoMembers.length.toLocaleString('fr-FR') }}
</span>
</p>
<p v-if="geoError" class="gw-text">Erreur Cesium+ : {{ geoError }}</p>
<!-- Loading map -->
<div v-if="geoLoading || !v1Pubkeys" class="flex flex-col items-center gap-2 py-4">
<div class="gw-spinner" />
<p class="gw-text text-sm">Chargement des profils g&eacute;olocalis&eacute;s...</p>
</div>
<!-- Map -->
<template v-else>
<GratewizardGwMap
:members="geoMembers"
:clear-trigger="clearTrigger"
:load-polygon="loadPolygonTrigger"
@polygon-change="handlePolygonChange"
/>
<p v-if="!loading && monetary" class="gw-text">
{{ geoMembers.length.toLocaleString('fr-FR') }} membres certifi&eacute;s g&eacute;olocalis&eacute;s sur
{{ monetary.membersCount.toLocaleString('fr-FR') }} membres au total
</p>
<!-- Save perimeter form -->
<div v-if="polygon" class="flex gap-2 items-center">
<input
v-model="perimeterName"
type="text"
placeholder="Nom du p&eacute;rim&egrave;tre"
class="gw-input"
@keydown.enter="handleSavePerimeter"
/>
<button
class="gw-btn gw-btn-accent"
:disabled="!perimeterName.trim()"
@click="handleSavePerimeter"
>
Sauvegarder
</button>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { duniter, type MonetaryData } from '~/services/duniter';
import { pointInPolygon, fr, formatValue, countUdSince, type CurrencyUnit } from '~/utils/gratewizard';
import type { GeoMember } from '~/composables/useCesiumProfiles';
import type { SavedPerimeter } from '~/composables/useSavedPerimeters';
const unit = useLocalStorage<CurrencyUnit>('gw-currency-unit', 'DU');
const monetary = ref<MonetaryData | null>(null);
const v1Pubkeys = ref<string[] | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const polygon = ref<[number, number][] | null>(null);
const clearTrigger = ref(0);
const loadPolygonTrigger = ref<{ coords: [number, number][]; name: string } | null>(null);
const perimeterName = ref('');
const activePerimeterName = ref<string | null>(null);
const localMNg1 = ref<number | null>(null);
const mnLoading = ref(false);
const topRef = ref<HTMLDivElement | null>(null);
const { geoMembers, loading: geoLoading, error: geoError } = useCesiumProfiles(v1Pubkeys);
const { perimeters, savePerimeter, deletePerimeter } = useSavedPerimeters();
// Fetch monetary data on mount
onMounted(async () => {
try {
const [mon, pubkeys] = await Promise.all([
duniter.fetchMonetary(),
duniter.fetchMemberPubkeys(),
]);
monetary.value = mon;
v1Pubkeys.value = pubkeys;
} catch (e: any) {
error.value = e.message;
} finally {
loading.value = false;
}
});
const duDaily = computed(() => monetary.value ? Number(monetary.value.amount) / 100 : 1);
const massG1 = computed(() => monetary.value ? Number(monetary.value.monetaryMass) / 100 : 0);
const mnG1 = computed(() => monetary.value && monetary.value.membersCount ? massG1.value / monetary.value.membersCount : 0);
const localMembers = computed(() => {
if (!polygon.value) return [];
return geoMembers.value.filter((m) => pointInPolygon(m.lat, m.lon, polygon.value!));
});
const nLocal = computed(() => localMembers.value.length);
// Compute local M/N based on member seniority
watch([polygon, localMembers, monetary], async ([poly, members, mon]) => {
if (!poly || members.length === 0 || !mon) {
localMNg1.value = null;
return;
}
mnLoading.value = true;
try {
const pubkeys = members.map((m) => m.pubkey);
const joinBlocks = await duniter.fetchMemberJoinBlocks(pubkeys);
const udBlocks = mon.udBlockNumbers;
let totalSeniority = 0;
let validCount = 0;
for (const pk of pubkeys) {
const joinBlock = joinBlocks.get(pk);
if (joinBlock === undefined) continue;
totalSeniority += countUdSince(udBlocks, joinBlock);
validCount++;
}
if (validCount > 0) {
const localAvgSeniority = totalSeniority / validCount;
const globalAvgSeniority =
Number(mon.monetaryMass) / (Number(mon.amount) * mon.membersCount);
localMNg1.value = mnG1.value * (localAvgSeniority / globalAvgSeniority);
}
} catch (e) {
console.error('Failed to compute local M/N:', e);
} finally {
mnLoading.value = false;
}
});
const displayN = computed(() => polygon.value ? nLocal.value : monetary.value?.membersCount ?? 0);
const displayMN = computed(() => polygon.value ? localMNg1.value : mnG1.value);
const displayM = computed(() => polygon.value ? (localMNg1.value !== null ? localMNg1.value * nLocal.value : null) : massG1.value);
const mnIsLoading = computed(() => polygon.value && (mnLoading.value || localMNg1.value === null));
function handlePolygonChange(poly: [number, number][] | null, name?: string) {
polygon.value = poly;
activePerimeterName.value = name ?? null;
if (poly) nextTick(() => topRef.value?.scrollIntoView({ behavior: 'smooth' }));
}
function handleClearSelection() {
polygon.value = null;
activePerimeterName.value = null;
localMNg1.value = null;
clearTrigger.value++;
loadPolygonTrigger.value = null;
}
function handleSavePerimeter() {
if (!polygon.value || !perimeterName.value.trim()) return;
savePerimeter(perimeterName.value.trim(), polygon.value);
perimeterName.value = '';
}
function handleLoadPerimeter(p: SavedPerimeter) {
loadPolygonTrigger.value = { coords: p.polygon, name: p.name };
}
</script>