Files
splitt-the-bill/app/components/BalanceSummary.vue
T
anbraten 99a9f908f1
tinyedge/deploy Deployed by TinyEdge
move tabs into own pages
2026-08-07 15:49:43 +02:00

304 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { ParticipantView, SettlementView } from '../types';
import { isValidPaymentLink } from '~~/shared/utils/paymentLink';
const props = defineProps<{
slug: string;
participants: ParticipantView[];
netBalances: Record<number, number>;
settlements: SettlementView[];
currency: string;
}>();
const emit = defineEmits<{ settled: []; updated: [] }>();
const { lastLink, rememberLink } = usePaymentLinkCache();
const showSettlements = ref(false);
const settlingKey = ref<string | null>(null);
const editingId = ref<number | null>(null);
const editValue = ref('');
const editError = ref('');
const savingLink = ref(false);
function nameOf(id: number) {
return props.participants.find((p) => p.id === id)?.name ?? 'Unknown';
}
function paymentLinkOf(id: number) {
return props.participants.find((p) => p.id === id)?.paymentLink || null;
}
function initialOf(participantName: string) {
return participantName.trim().charAt(0).toUpperCase() || '?';
}
function keyOf(settlement: SettlementView) {
return `${settlement.fromId}-${settlement.toId}-${settlement.amountCents}`;
}
function startEdit(participant: ParticipantView) {
editingId.value = participant.id;
editValue.value = participant.paymentLink ?? lastLink.value ?? '';
editError.value = '';
}
function cancelEdit() {
editingId.value = null;
editError.value = '';
}
async function saveEdit(participant: ParticipantView) {
const trimmed = editValue.value.trim();
if (trimmed && !isValidPaymentLink(trimmed)) {
editError.value = 'Enter a valid link starting with http:// or https://.';
return;
}
savingLink.value = true;
try {
await $fetch(`/api/pots/${props.slug}/participants/${participant.id}`, {
method: 'PATCH',
body: { paymentLink: trimmed },
});
if (trimmed) rememberLink(trimmed);
editingId.value = null;
emit('updated');
} finally {
savingLink.value = false;
}
}
async function markSettled(settlement: SettlementView) {
const key = keyOf(settlement);
settlingKey.value = key;
try {
await $fetch(`/api/pots/${props.slug}/payments`, {
method: 'POST',
body: {
fromId: settlement.fromId,
toId: settlement.toId,
amountCents: settlement.amountCents,
},
});
emit('settled');
} finally {
settlingKey.value = null;
}
}
</script>
<template>
<div class="space-y-4">
<p
v-if="participants.length === 0"
class="rounded-2xl border border-dashed border-stone-300 p-4 text-center text-sm text-stone-500 dark:border-stone-700 dark:text-stone-400"
>
No one's in this Pot yet. Add people from the settings page.
</p>
<ul v-else class="space-y-2">
<li
v-for="participant in participants"
:key="participant.id"
class="rounded-2xl border border-stone-200 bg-white p-3 shadow-sm dark:border-stone-800 dark:bg-stone-900"
>
<div class="flex items-center justify-between gap-3">
<button
type="button"
class="flex min-w-0 items-center gap-3 text-left"
@click="
editingId === participant.id
? cancelEdit()
: startEdit(participant)
"
>
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-100 text-sm font-semibold text-orange-700 dark:bg-orange-900/40 dark:text-orange-300"
>
{{ initialOf(participant.name) }}
</div>
<span
class="truncate font-medium text-stone-900 dark:text-stone-100"
>{{ participant.name }}</span
>
</button>
<div class="flex shrink-0 items-center gap-3">
<a
v-if="participant.paymentLink"
:href="participant.paymentLink"
target="_blank"
rel="noopener noreferrer"
class="text-xs font-medium text-orange-600 hover:underline dark:text-orange-400"
@click.stop
>
Payment link
</a>
<span
class="text-sm font-semibold"
:class="{
'text-emerald-600 dark:text-emerald-400':
(netBalances[participant.id] ?? 0) > 0,
'text-rose-600 dark:text-rose-400':
(netBalances[participant.id] ?? 0) < 0,
'text-stone-400 dark:text-stone-500':
(netBalances[participant.id] ?? 0) === 0,
}"
>
<template v-if="(netBalances[participant.id] ?? 0) === 0">
settled up
</template>
<template v-else>
{{ (netBalances[participant.id] ?? 0) > 0 ? '+' : ''
}}{{
formatCurrency(
Math.abs(netBalances[participant.id] ?? 0),
currency,
)
}}
</template>
</span>
</div>
</div>
<div
v-if="editingId === participant.id"
class="mt-3 space-y-2 border-t border-stone-100 pt-3 dark:border-stone-800"
>
<label
class="block text-xs font-medium text-stone-500 dark:text-stone-400"
>
Payment link for {{ participant.name }}
</label>
<div class="flex gap-2">
<input
v-model="editValue"
type="url"
placeholder="https://paypal.me/yourname"
class="block w-full rounded-xl border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 shadow-sm placeholder:text-stone-400 focus:border-orange-400 focus:outline-none focus:ring-2 focus:ring-orange-200 dark:border-stone-700 dark:bg-stone-950 dark:text-stone-100 dark:placeholder:text-stone-500 dark:focus:ring-orange-900"
@keyup.enter="saveEdit(participant)"
/>
<button
type="button"
:disabled="savingLink"
class="shrink-0 rounded-xl bg-orange-500 px-3 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-orange-600 disabled:opacity-50"
@click="saveEdit(participant)"
>
{{ savingLink ? 'Saving' : 'Save' }}
</button>
<button
type="button"
class="shrink-0 rounded-xl border border-stone-300 px-3 py-2 text-sm font-medium text-stone-600 transition-colors hover:border-orange-300 hover:text-orange-600 dark:border-stone-700 dark:text-stone-300 dark:hover:border-orange-700 dark:hover:text-orange-400"
@click="cancelEdit"
>
Cancel
</button>
</div>
<p v-if="editError" class="text-xs text-rose-600 dark:text-rose-400">
{{ editError }}
</p>
</div>
</li>
</ul>
<div v-if="participants.length > 0" class="space-y-2">
<button
type="button"
class="flex w-full items-center justify-between gap-2 text-sm font-medium text-orange-600 dark:text-orange-400"
@click="showSettlements = !showSettlements"
>
<span>Suggested payments</span>
<svg
class="h-4 w-4 shrink-0 transition-transform"
:class="{ 'rotate-180': showSettlements }"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.17l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
<p
v-if="showSettlements && settlements.length === 0"
class="text-sm text-stone-500 dark:text-stone-400"
>
Everyone is settled up.
</p>
<ul v-else-if="showSettlements" class="space-y-2">
<li
v-for="settlement in settlements"
:key="keyOf(settlement)"
class="space-y-2 rounded-2xl border border-orange-100 bg-gradient-to-r from-orange-50 to-amber-50 p-3 dark:border-orange-900/60 dark:from-orange-950/40 dark:to-amber-950/30"
>
<div class="flex items-center gap-2">
<div class="flex min-w-0 flex-1 items-center gap-2">
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-orange-700 shadow-sm ring-1 ring-orange-200 dark:bg-stone-900 dark:text-orange-300 dark:ring-orange-800"
>
{{ initialOf(nameOf(settlement.fromId)) }}
</div>
<span
class="truncate text-sm font-medium text-stone-700 dark:text-stone-300"
>{{ nameOf(settlement.fromId) }}</span
>
<svg
class="h-4 w-4 shrink-0 text-orange-400 dark:text-orange-500"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 0 1 .75-.75h10.638L11.29 6.15a.75.75 0 1 1 1.02-1.1l4.5 4.25a.75.75 0 0 1 0 1.1l-4.5 4.25a.75.75 0 1 1-1.02-1.1l3.098-3.1H3.75A.75.75 0 0 1 3 10Z"
clip-rule="evenodd"
/>
</svg>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-orange-700 shadow-sm ring-1 ring-orange-200 dark:bg-stone-900 dark:text-orange-300 dark:ring-orange-800"
>
{{ initialOf(nameOf(settlement.toId)) }}
</div>
<span
class="truncate text-sm font-medium text-stone-700 dark:text-stone-300"
>{{ nameOf(settlement.toId) }}</span
>
</div>
<span
class="shrink-0 rounded-full bg-white px-2.5 py-1 text-sm font-bold text-orange-700 shadow-sm dark:bg-stone-900 dark:text-orange-300"
>
{{ formatCurrency(settlement.amountCents, currency) }}
</span>
</div>
<div class="flex items-center justify-end gap-2">
<a
v-if="paymentLinkOf(settlement.toId)"
:href="paymentLinkOf(settlement.toId)!"
target="_blank"
rel="noopener noreferrer"
class="rounded-full border border-orange-300 px-3 py-1 text-xs font-medium text-orange-700 transition-colors hover:bg-white dark:border-orange-800 dark:text-orange-300 dark:hover:bg-stone-900"
>
Pay now
</a>
<button
type="button"
:disabled="settlingKey === keyOf(settlement)"
class="rounded-full bg-orange-500 px-3 py-1 text-xs font-semibold text-white shadow-sm transition-colors hover:bg-orange-600 disabled:opacity-50"
@click="markSettled(settlement)"
>
{{
settlingKey === keyOf(settlement) ? 'Marking' : 'Mark as paid'
}}
</button>
</div>
</li>
</ul>
</div>
</div>
</template>