503 lines
19 KiB
Vue
503 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import dayjs from "dayjs"
|
|
|
|
definePageMeta({
|
|
layout: "notLoggedIn"
|
|
})
|
|
|
|
const auth = useAuthStore()
|
|
const toast = useToast()
|
|
|
|
const customer = ref<any | null>(null)
|
|
const contracts = ref<any[]>([])
|
|
const invoices = ref<any[]>([])
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const expandedInvoiceId = ref<number | null>(null)
|
|
const downloadingInvoiceId = ref<number | null>(null)
|
|
const activeTab = ref("0")
|
|
|
|
const customerForm = reactive({
|
|
name: "",
|
|
firstname: "",
|
|
lastname: "",
|
|
salutation: "",
|
|
title: "",
|
|
nameAddition: "",
|
|
email: "",
|
|
invoiceEmail: "",
|
|
phone: "",
|
|
mobile: "",
|
|
website: "",
|
|
street: "",
|
|
special: "",
|
|
zip: "",
|
|
city: "",
|
|
country: ""
|
|
})
|
|
|
|
const portalCustomerId = computed(() => auth.profile?.customer_for_portal || null)
|
|
|
|
const summaryCards = computed(() => [
|
|
{
|
|
label: "Offene Rechnungen",
|
|
value: invoices.value.filter((invoice) => getOpenAmount(invoice) > 0).length,
|
|
icon: "i-heroicons-document-text"
|
|
},
|
|
{
|
|
label: "Aktive Vertrage",
|
|
value: contracts.value.filter((contract) => contract.active).length,
|
|
icon: "i-heroicons-clipboard-document-check"
|
|
},
|
|
{
|
|
label: "Dokumente im Portal",
|
|
value: invoices.value.length,
|
|
icon: "i-heroicons-folder-open"
|
|
}
|
|
])
|
|
|
|
const portalTabs = computed(() => [
|
|
{
|
|
label: "Kundendaten",
|
|
slot: "customer",
|
|
value: "0"
|
|
},
|
|
{
|
|
label: `Rechnungen (${invoices.value.length})`,
|
|
slot: "invoices",
|
|
value: "1"
|
|
},
|
|
{
|
|
label: `Verträge (${contracts.value.length})`,
|
|
slot: "contracts",
|
|
value: "2"
|
|
}
|
|
])
|
|
|
|
function fillFormFromCustomer(record: any) {
|
|
const infoData = record?.infoData || {}
|
|
|
|
customerForm.name = record?.name || ""
|
|
customerForm.firstname = record?.firstname || ""
|
|
customerForm.lastname = record?.lastname || ""
|
|
customerForm.salutation = record?.salutation || ""
|
|
customerForm.title = record?.title || ""
|
|
customerForm.nameAddition = record?.nameAddition || ""
|
|
customerForm.email = infoData.email || ""
|
|
customerForm.invoiceEmail = infoData.invoiceEmail || ""
|
|
customerForm.phone = infoData.tel || ""
|
|
customerForm.mobile = infoData.mobileTel || ""
|
|
customerForm.website = infoData.web || ""
|
|
customerForm.street = infoData.street || ""
|
|
customerForm.special = infoData.special || ""
|
|
customerForm.zip = infoData.zip || ""
|
|
customerForm.city = infoData.city || ""
|
|
customerForm.country = infoData.country || ""
|
|
}
|
|
|
|
function getDocumentTypeLabel(type: string) {
|
|
if (type === "advanceInvoices") return "Abschlagsrechnung"
|
|
if (type === "cancellationInvoices") return "Stornorechnung"
|
|
return "Rechnung"
|
|
}
|
|
|
|
function formatDate(value?: string | null) {
|
|
if (!value) return "-"
|
|
|
|
const date = dayjs(value)
|
|
return date.isValid() ? date.format("DD.MM.YYYY") : value
|
|
}
|
|
|
|
function formatCurrency(value: number) {
|
|
return useCurrency(value)
|
|
}
|
|
|
|
function getInvoiceAmount(invoice: any) {
|
|
return useSum().getCreatedDocumentSum(invoice, invoices.value)
|
|
}
|
|
|
|
function getOpenAmount(invoice: any) {
|
|
return useSum().getCreatedDocumentOpenAmount(invoice, invoices.value)
|
|
}
|
|
|
|
function toggleInvoice(invoiceId: number) {
|
|
expandedInvoiceId.value = expandedInvoiceId.value === invoiceId ? null : invoiceId
|
|
}
|
|
|
|
async function logout() {
|
|
await auth.logout()
|
|
}
|
|
|
|
async function downloadInvoice(invoice: any) {
|
|
const fileId = invoice?.files?.[0]?.id
|
|
if (!fileId) return
|
|
|
|
downloadingInvoiceId.value = invoice.id
|
|
|
|
try {
|
|
await useFiles().downloadFile(fileId)
|
|
} finally {
|
|
downloadingInvoiceId.value = null
|
|
}
|
|
}
|
|
|
|
async function loadPortalData() {
|
|
if (!portalCustomerId.value) {
|
|
loading.value = false
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
|
|
try {
|
|
const [customerRecord, contractRows, invoiceRows] = await Promise.all([
|
|
useEntities("customers").selectSingle(portalCustomerId.value),
|
|
useEntities("contracts").select("*, contracttype(id,name)", "startDate", true),
|
|
useEntities("createddocuments").select("*, files(*), statementallocations(*), contract(id,name,contractNumber)", "documentDate", true)
|
|
])
|
|
|
|
customer.value = customerRecord
|
|
contracts.value = (contractRows || []).filter((item: any) => !item.archived)
|
|
invoices.value = (invoiceRows || []).filter((item: any) => !item.archived)
|
|
fillFormFromCustomer(customerRecord)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function saveCustomerData() {
|
|
if (!customer.value?.id) return
|
|
|
|
saving.value = true
|
|
|
|
try {
|
|
const payload = {
|
|
name: customerForm.name,
|
|
firstname: customerForm.firstname,
|
|
lastname: customerForm.lastname,
|
|
salutation: customerForm.salutation,
|
|
title: customerForm.title,
|
|
nameAddition: customerForm.nameAddition,
|
|
infoData: {
|
|
...(customer.value.infoData || {}),
|
|
email: customerForm.email,
|
|
invoiceEmail: customerForm.invoiceEmail,
|
|
tel: customerForm.phone,
|
|
mobileTel: customerForm.mobile,
|
|
web: customerForm.website,
|
|
street: customerForm.street,
|
|
special: customerForm.special,
|
|
zip: customerForm.zip,
|
|
city: customerForm.city,
|
|
country: customerForm.country
|
|
}
|
|
}
|
|
|
|
const updated = await useEntities("customers").update(customer.value.id, payload, true)
|
|
customer.value = updated
|
|
fillFormFromCustomer(updated)
|
|
toast.add({ title: "Kundendaten gespeichert" })
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadPortalData()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100">
|
|
<UContainer class="py-8 lg:py-12">
|
|
<div class="flex flex-col gap-6">
|
|
<div class="rounded-3xl border border-neutral-200 bg-white/90 p-6 shadow-sm lg:p-8">
|
|
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
|
<div class="space-y-3">
|
|
<p class="text-sm font-medium uppercase tracking-[0.2em] text-primary-600">Kundenportal</p>
|
|
<div>
|
|
<h1 class="text-3xl font-semibold text-neutral-900">
|
|
{{ customer?.name || "Ihr Zugang" }}
|
|
</h1>
|
|
<p class="mt-2 max-w-2xl text-sm text-neutral-600">
|
|
Hier konnen Sie Ihre Kundendaten pflegen, Rechnungen einsehen und aktive Vertrage im Blick behalten.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-3">
|
|
<UButton color="neutral" variant="soft" icon="i-heroicons-arrow-path" :loading="loading" @click="loadPortalData">
|
|
Aktualisieren
|
|
</UButton>
|
|
<UButton color="neutral" variant="outline" icon="i-heroicons-arrow-right-on-rectangle" @click="logout">
|
|
Abmelden
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6 grid gap-4 md:grid-cols-3">
|
|
<div
|
|
v-for="card in summaryCards"
|
|
:key="card.label"
|
|
class="rounded-2xl border border-neutral-200 bg-neutral-50 p-4"
|
|
>
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p class="text-sm text-neutral-500">{{ card.label }}</p>
|
|
<p class="mt-2 text-2xl font-semibold text-neutral-900">{{ card.value }}</p>
|
|
</div>
|
|
<div class="rounded-xl bg-white p-2 text-primary-600 shadow-sm">
|
|
<UIcon :name="card.icon" class="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="rounded-3xl border border-neutral-200 bg-white p-8 text-center text-neutral-500">
|
|
Portal wird geladen...
|
|
</div>
|
|
|
|
<div v-else-if="!portalCustomerId" class="rounded-3xl border border-amber-200 bg-amber-50 p-8 text-amber-900">
|
|
Fuer diesen Zugang ist noch kein Portal-Kunde hinterlegt.
|
|
</div>
|
|
|
|
<UCard v-else :ui="{ body: 'p-4 sm:p-6' }">
|
|
<UTabs v-model="activeTab" :items="portalTabs" class="portal-tabs">
|
|
<template #customer>
|
|
<div class="pt-4">
|
|
<div class="mb-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-neutral-900">Kundendaten</h2>
|
|
<p class="mt-1 text-sm text-neutral-500">Aenderungen werden direkt in Ihrem Kundenstamm gespeichert.</p>
|
|
</div>
|
|
<UBadge color="primary" variant="subtle">Bearbeitbar</UBadge>
|
|
</div>
|
|
|
|
<div class="grid gap-4 md:grid-cols-2">
|
|
<UFormField label="Name / Firma">
|
|
<UInput v-model="customerForm.name" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Firmenzusatz">
|
|
<UInput v-model="customerForm.nameAddition" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Anrede">
|
|
<UInput v-model="customerForm.salutation" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Titel">
|
|
<UInput v-model="customerForm.title" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Vorname">
|
|
<UInput v-model="customerForm.firstname" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Nachname">
|
|
<UInput v-model="customerForm.lastname" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="E-Mail">
|
|
<UInput v-model="customerForm.email" type="email" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Rechnungs-E-Mail">
|
|
<UInput v-model="customerForm.invoiceEmail" type="email" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Telefon">
|
|
<UInput v-model="customerForm.phone" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Mobil">
|
|
<UInput v-model="customerForm.mobile" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Webseite">
|
|
<UInput v-model="customerForm.website" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Land">
|
|
<UInput v-model="customerForm.country" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Strasse" class="md:col-span-2">
|
|
<UInput v-model="customerForm.street" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Adresszusatz" class="md:col-span-2">
|
|
<UInput v-model="customerForm.special" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="PLZ">
|
|
<UInput v-model="customerForm.zip" class="w-full" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Ort">
|
|
<UInput v-model="customerForm.city" class="w-full" />
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="mt-6 flex justify-end">
|
|
<UButton icon="i-heroicons-check" :loading="saving" @click="saveCustomerData">
|
|
Kundendaten speichern
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #invoices>
|
|
<div class="pt-4">
|
|
<div class="mb-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-neutral-900">Rechnungen</h2>
|
|
<p class="mt-1 text-sm text-neutral-500">Alle fuer das Portal freigegebenen Rechnungen Ihres Kundenkontos.</p>
|
|
</div>
|
|
<UBadge color="neutral" variant="subtle">{{ invoices.length }}</UBadge>
|
|
</div>
|
|
|
|
<div v-if="!invoices.length" class="rounded-2xl border border-dashed border-neutral-200 p-6 text-sm text-neutral-500">
|
|
Aktuell sind keine Rechnungen im Portal verfuegbar.
|
|
</div>
|
|
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="invoice in invoices"
|
|
:key="invoice.id"
|
|
class="rounded-2xl border border-neutral-200 bg-neutral-50 p-4"
|
|
>
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<p class="font-semibold text-neutral-900">{{ invoice.documentNumber || `Dokument #${invoice.id}` }}</p>
|
|
<UBadge color="primary" variant="soft">{{ getDocumentTypeLabel(invoice.type) }}</UBadge>
|
|
<UBadge color="neutral" variant="soft">{{ invoice.state }}</UBadge>
|
|
</div>
|
|
<p class="mt-2 text-sm text-neutral-500">
|
|
Datum: {{ formatDate(invoice.documentDate) }}
|
|
<span v-if="invoice.contract?.name"> | Vertrag: {{ invoice.contract.contractNumber || "-" }} {{ invoice.contract.name }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-col items-start gap-3 text-sm lg:items-end">
|
|
<div class="text-neutral-600">
|
|
Gesamt: <span class="font-semibold text-neutral-900">{{ formatCurrency(getInvoiceAmount(invoice)) }}</span>
|
|
</div>
|
|
<div class="text-neutral-600">
|
|
Offen: <span class="font-semibold text-neutral-900">{{ formatCurrency(getOpenAmount(invoice)) }}</span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<UButton
|
|
v-if="invoice.files?.length"
|
|
color="primary"
|
|
variant="soft"
|
|
size="sm"
|
|
icon="i-heroicons-arrow-down-tray"
|
|
:loading="downloadingInvoiceId === invoice.id"
|
|
@click="downloadInvoice(invoice)"
|
|
>
|
|
PDF herunterladen
|
|
</UButton>
|
|
<UButton color="neutral" variant="soft" size="sm" @click="toggleInvoice(invoice.id)">
|
|
{{ expandedInvoiceId === invoice.id ? "Details ausblenden" : "Details ansehen" }}
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="expandedInvoiceId === invoice.id" class="mt-4 border-t border-neutral-200 pt-4">
|
|
<div v-if="invoice.rows?.length" class="space-y-3">
|
|
<div
|
|
v-for="(row, index) in invoice.rows.filter((entry: any) => !['pagebreak'].includes(entry.mode))"
|
|
:key="`${invoice.id}-${index}`"
|
|
class="flex flex-col gap-1 rounded-xl bg-white p-3 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="pr-4">
|
|
<p class="font-medium text-neutral-900">{{ row.text || row.title || row.description || "Position" }}</p>
|
|
<p class="text-xs text-neutral-500">
|
|
Menge: {{ row.quantity || "-" }} | Einzelpreis: {{ formatCurrency(Number(row.price || 0)) }}
|
|
</p>
|
|
</div>
|
|
<div class="text-sm font-medium text-neutral-900">
|
|
{{ formatCurrency((Number(row.quantity || 0) * Number(row.price || 0)) * (1 - Number(row.discountPercent || 0) / 100)) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-sm text-neutral-500">
|
|
Fuer dieses Dokument liegen keine Positionsdaten vor.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #contracts>
|
|
<div class="pt-4">
|
|
<div class="mb-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-neutral-900">Vertraege</h2>
|
|
<p class="mt-1 text-sm text-neutral-500">Ihre aktuell zugeordneten Vertrage auf einen Blick.</p>
|
|
</div>
|
|
<UBadge color="neutral" variant="subtle">{{ contracts.length }}</UBadge>
|
|
</div>
|
|
|
|
<div v-if="!contracts.length" class="rounded-2xl border border-dashed border-neutral-200 p-6 text-sm text-neutral-500">
|
|
Es sind aktuell keine Vertraege hinterlegt.
|
|
</div>
|
|
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="contract in contracts"
|
|
:key="contract.id"
|
|
class="rounded-2xl border border-neutral-200 bg-neutral-50 p-4"
|
|
>
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<p class="font-semibold text-neutral-900">{{ contract.name }}</p>
|
|
<UBadge :color="contract.active ? 'green' : 'neutral'" variant="soft">
|
|
{{ contract.active ? "Aktiv" : "Inaktiv" }}
|
|
</UBadge>
|
|
</div>
|
|
<p class="mt-2 text-sm text-neutral-500">
|
|
Vertragsnummer: {{ contract.contractNumber || "-" }}
|
|
</p>
|
|
</div>
|
|
<div class="text-right text-sm text-neutral-500">
|
|
<p>{{ contract.contracttype?.name || "Ohne Vertragstyp" }}</p>
|
|
<p>{{ contract.billingInterval || "Kein Intervall hinterlegt" }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
|
<div class="rounded-xl bg-white p-3">
|
|
<p class="text-xs uppercase tracking-wide text-neutral-400">Laufzeit</p>
|
|
<p class="mt-1 text-sm font-medium text-neutral-900">
|
|
{{ formatDate(contract.startDate) }} bis {{ formatDate(contract.endDate) }}
|
|
</p>
|
|
</div>
|
|
<div class="rounded-xl bg-white p-3">
|
|
<p class="text-xs uppercase tracking-wide text-neutral-400">Abrechnung</p>
|
|
<p class="mt-1 text-sm font-medium text-neutral-900">
|
|
{{ contract.paymentType || "Nicht hinterlegt" }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="contract.notes" class="mt-4 text-sm text-neutral-600">
|
|
{{ contract.notes }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UTabs>
|
|
</UCard>
|
|
</div>
|
|
</UContainer>
|
|
</div>
|
|
</template>
|