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:
272
app/components/gratewizard/GwMN.vue
Normal file
272
app/components/gratewizard/GwMN.vue
Normal 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">Ğ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é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é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) }} Ğ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é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é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 }} —
|
||||
{{ 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érimè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éolocalisé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éolocalisé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és géolocalisé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érimè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>
|
||||
Reference in New Issue
Block a user