Files
mareike/app/Domains/ParticipantRefund/Views/RefundPage.vue
T

465 lines
18 KiB
Vue

<script setup>
import {computed, reactive, ref} from 'vue'
import {toast} from 'vue3-toastify'
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import ShadowedBox from '../../../Views/Components/ShadowedBox.vue'
import ErrorText from '../../../Views/Components/ErrorText.vue'
import IbanInput from '../../../Views/Components/IbanInput.vue'
import TextResource from '../../../Views/Components/TextResource.vue'
import {useAjax} from '../../../../resources/js/components/ajaxHandler.js'
const {request} = useAjax()
/**
* `state` steuert die ganze Seite:
* open -- Formular für die Bankverbindung
* accepted -- Angaben liegen vor, nichts mehr zu tun
* unavailable -- Token unbekannt oder Erstattung abgebrochen
*/
const props = defineProps({
state: String,
message: String,
token: String,
name: String,
eventTitle: String,
eventEmail: String,
amount: String,
reason: String,
reasonNote: String,
acceptedAt: String,
donation: Boolean,
})
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
// Ob gespendet wurde -- für den Abschlusstext. Nach dem Absenden setzt submit() den Wert selbst, ein
// Neuladen holt ihn vom Controller.
const donated = ref(props.donation === true)
/**
* Der Weg durch die Seite, nach dem Muster der Auslagenabrechnung (refund-data.vue): erst die
* Entscheidung, dann die Angaben dazu. Nichts wird gefragt, was für den gewählten Weg keine Rolle spielt.
*
* decision -- '' (noch nichts gewählt) | 'donation' (spenden) | 'payout' (auszahlen lassen)
* sameAccount -- nur bei 'payout': ob es das Konto der Ursprungszahlung ist
*/
const decision = ref('')
const sameAccount = ref(null)
const form = reactive({
accountOwner: '',
accountIban: '',
declarationAccepted: false,
accountDeclarationAccepted: false,
})
const errors = reactive({accountOwner: '', accountIban: '', declaration: '', accountDeclaration: ''})
const saving = ref(false)
/**
* Die Erklärung erscheint erst, wenn beide Kontoangaben stehen -- man bestätigt nichts, bevor man weiß,
* worüber. Dasselbe Vorgehen wie beim Erfassen einer Abrechnung (refund-data.vue).
*
* Dort wird zusätzlich auf `iban.length === 27` geprüft, also auf die Länge einer formatierten deutschen
* IBAN. Das bleibt hier bewusst weg: eine österreichische oder schweizerische käme sonst nie durch. Ob
* die IBAN stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge.
*/
const accountComplete = computed(
() => form.accountOwner.trim() !== '' && form.accountIban.trim() !== ''
)
const isDonation = computed(() => decision.value === 'donation')
/**
* Wechselt den Weg und nimmt dabei jedes Kreuz zurück.
*
* Ohne das Zurücksetzen stünde eine Erklärung als bestätigt da, die in diesem Weg gar nicht gezeigt
* wurde -- wer erst die Auszahlung ankreuzt und dann zur Spende wechselt, hätte den Verzicht nie gelesen.
*/
function choose(next) {
decision.value = next
sameAccount.value = null
form.declarationAccepted = false
form.accountDeclarationAccepted = false
Object.keys(errors).forEach((key) => (errors[key] = ''))
}
/** Zurück zur ersten Frage -- aus der Sackgasse „anderes Konto" führt der Weg zur Spende. */
function reset() {
choose('')
}
function validate() {
Object.keys(errors).forEach((key) => (errors[key] = ''))
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
if (!isDonation.value) {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.'
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
errors.accountDeclaration = form.accountDeclarationAccepted
? ''
: 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.'
}
return Object.values(errors).every((message) => message === '')
}
async function submit() {
if (!validate() || saving.value) return
saving.value = true
// Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
const body = isDonation.value
? {donation: true, declarationAccepted: form.declarationAccepted}
: {
donation: false,
accountOwner: form.accountOwner,
accountIban: form.accountIban,
declarationAccepted: form.declarationAccepted,
accountDeclarationAccepted: form.accountDeclarationAccepted,
}
try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', {
method: 'POST',
body,
})
if (!response) {
toast.error('Deine Angaben konnten nicht gespeichert werden. Bitte versuche es später erneut.')
return
}
// Feldbezogene Fehler kommen mit Status 200 als `error_types` zurück -- der HttpClient des
// Projekts verwirft Antworten mit Fehlerstatus.
if (response.status !== 'success') {
Object.keys(response.error_types ?? {}).forEach((key) => {
if (key in errors) errors[key] = response.error_types[key]
})
toast.error(response.message ?? 'Bitte prüfe deine Angaben.')
return
}
toast.success(response.message)
donated.value = isDonation.value
state.value = 'accepted'
} finally {
saving.value = false
}
}
</script>
<template>
<AppLayout title="Rückerstattung">
<shadowed-box style="max-width: 640px; margin: 60px auto; padding: 24px;">
<template v-if="state === 'unavailable'">
<h2>Rückerstattung</h2>
<p class="hint">{{ props.message }}</p>
</template>
<template v-else>
<h2>Rückerstattung deines Teilnahmebeitrags</h2>
<p>
Hallo {{ props.name }}, für deine Abmeldung von der Veranstaltung
<strong>{{ props.eventTitle }}</strong> wurde eine Rückerstattung freigegeben.
</p>
<table class="summary">
<tr>
<th>Betrag</th>
<td><strong>{{ props.amount }}</strong></td>
</tr>
<tr>
<th>Grund</th>
<td>{{ props.reason }}</td>
</tr>
<tr v-if="props.reasonNote">
<th>Anmerkung</th>
<td>{{ props.reasonNote }}</td>
</tr>
</table>
<template v-if="state === 'accepted'">
<p v-if="donated" class="hint">
Du hast den Betrag gespendet<span v-if="props.acceptedAt"> (am {{ props.acceptedAt }})</span>
&ndash; vielen Dank. Es gibt nichts weiter zu tun. Stimmt etwas nicht, wende dich
bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<p v-else class="hint">
Deine Angaben liegen uns vor<span v-if="props.acceptedAt"> (seit {{ props.acceptedAt }})</span>.
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte
an sie: {{ props.eventEmail }}
</p>
</template>
<template v-else>
<p class="hint">
Der Betrag steht fest und lässt sich hier nicht ändern. Hast du dazu Fragen, wende
dich bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<form @submit.prevent="submit">
<!--
Erste Frage: Wer spendet, braucht kein Konto anzugeben. Deshalb steht sie
vor allem anderen.
-->
<h3>Möchtest du den Betrag stattdessen spenden?</h3>
<p class="choice-hint">
Spendest du, verbleibt der Betrag beim Verband und kommt unserer Arbeit
zugute. Wir brauchen dann keine Bankverbindung von dir.
</p>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: decision === 'donation'}"
@click="choose('donation')"
>Ja, spenden</button>
<button
type="button"
class="choice"
:class="{active: decision === 'payout'}"
@click="choose('payout')"
>Nein, bitte erstatten</button>
</div>
<!-- Spendenweg: nur der Verzicht, keine Kontoangaben. -->
<template v-if="decision === 'donation'">
<div class="declaration">
<input
id="refund-donation-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_DONATION"
belongs-to="refund-donation-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<button
v-if="form.declarationAccepted"
class="button"
type="submit"
:disabled="saving"
>
{{ saving ? 'Wird gespeichert…' : 'Betrag spenden' }}
</button>
</template>
<!--
Auszahlungsweg: Erstattet wird ausschließlich auf das Konto, von dem der
Beitrag kam. Das wird gefragt, bevor überhaupt eine IBAN eingegeben wird --
sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
-->
<template v-else-if="decision === 'payout'">
<h3>Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
gezahlt wurde?</h3>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: sameAccount === true}"
@click="sameAccount = true"
>Ja</button>
<button
type="button"
class="choice"
:class="{active: sameAccount === false}"
@click="sameAccount = false"
>Nein</button>
</div>
<template v-if="sameAccount === false">
<p class="hint">
So können wir den Betrag leider nicht erstatten: Zurück geht er nur auf
das Konto, von dem der Teilnahmebeitrag gezahlt wurde. Wurde er von
einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils
&ndash;, gib bitte dieses an. Hilft das nicht weiter, wende dich bitte
an die Aktionsleitung: {{ props.eventEmail }}
</p>
<button type="button" class="button" @click="reset()">
Zurück zur Auswahl
</button>
</template>
<template v-else-if="sameAccount === true">
<div class="field">
<label for="account-owner">Kontoinhaber*in</label>
<input
id="account-owner"
v-model="form.accountOwner"
type="text"
class="form-input"
autocomplete="name"
/>
<ErrorText :message="errors.accountOwner" />
</div>
<div class="field">
<label for="account-iban">IBAN</label>
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" />
</div>
<!--
Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie
müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg
dem Teili Zusicherungen zu, die er nie abgegeben hat.
-->
<template v-if="accountComplete">
<div class="declaration">
<input
id="refund-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND"
belongs-to="refund-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<div class="declaration">
<input
id="refund-account-declaration"
v-model="form.accountDeclarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT"
belongs-to="refund-account-declaration"
/>
</div>
<ErrorText :message="errors.accountDeclaration" />
<!-- Beim Speichern gesperrt statt ausgeblendet, sonst verschwände der
Knopf unter dem Finger. -->
<button
v-if="form.declarationAccepted && form.accountDeclarationAccepted"
class="button"
type="submit"
:disabled="saving"
>
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button>
</template>
</template>
</template>
</form>
</template>
</template>
</shadowed-box>
</AppLayout>
</template>
<style scoped>
h2 {
margin-bottom: 16px;
}
h3 {
margin: 24px 0 12px;
}
.hint {
margin: 16px 0;
padding: 10px 12px;
border-left: 3px solid #f5c400;
background-color: #fffef5;
font-size: 0.9rem;
color: #4b5563;
}
.summary {
border-collapse: collapse;
margin: 20px 0;
}
.summary th {
text-align: left;
padding: 6px 24px 6px 0;
color: #555;
font-weight: normal;
white-space: nowrap;
}
.summary td {
padding: 6px 0;
}
.choice-hint {
margin-bottom: 12px;
font-size: 0.9rem;
color: #4b5563;
}
.choices {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.choice {
flex: 1;
padding: 10px 12px;
border: 1px solid #d8dde6;
border-radius: 0;
background: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.choice.active {
border-color: #1a4799;
background: #eef2fa;
font-weight: bold;
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
margin-bottom: 4px;
font-size: 0.9rem;
color: #4b5563;
}
.field .form-input {
width: 100%;
}
.declaration {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 20px 0 8px;
font-size: 0.9rem;
line-height: 1.5;
}
.declaration input {
margin-top: 3px;
flex-shrink: 0;
}
.declaration :deep(label) {
cursor: pointer;
}
</style>