Add initial Code

This commit is contained in:
2025-10-20 08:57:51 +02:00
parent d204098d8e
commit 9da301c4f1
447 changed files with 34393 additions and 0 deletions
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed } from 'vue'
import { type Invoice } from '@/types'
import { toLocalDate, toCurrency } from '@/lib/utils'
import { StatusBadge, statusBadgeLabels, statusBadgeTextColor, castToStatusVariant } from '@/components/ui/status-badge'
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
const props = defineProps<{
invoices: Invoice[],
onItemClicked(invoice: Invoice): void
}>()
const totalPaid = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'paid') amount += invoice.totalAmount
})
return amount
})
const totalTaxPaid = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'paid') amount += calcTaxes(invoice.totalAmount)
})
return amount
})
const totalGrossPaid = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'paid') amount += invoice.totalAmount + calcTaxes(invoice.totalAmount)
})
return amount
})
const totalDue = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (['issued', 'due', 'reminded'].includes(invoice.paymentStatus)) amount += invoice.totalAmount
})
return amount
})
const totalTaxDue = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (['issued', 'due', 'reminded'].includes(invoice.paymentStatus)) amount += calcTaxes(invoice.totalAmount)
})
return amount
})
const totalGrossDue = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (['issued', 'due', 'reminded'].includes(invoice.paymentStatus)) amount += invoice.totalAmount + calcTaxes(invoice.totalAmount)
})
return amount
})
const totalNotIssued = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'draft') amount += invoice.totalAmount
})
return amount
})
const totalTaxNotIssued = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'draft') amount += calcTaxes(invoice.totalAmount)
})
return amount
})
const totalGrossNotIssued = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'draft') amount += invoice.totalAmount + calcTaxes(invoice.totalAmount)
})
return amount
})
const totalAmount = computed(() => {
return totalPaid.value + totalDue.value + totalNotIssued.value
})
const totalTax = computed(() => {
return totalTaxPaid.value + totalTaxDue.value + totalTaxNotIssued.value
})
const totalGross = computed(() => {
return totalGrossPaid.value + totalGrossDue.value + totalGrossNotIssued.value
})
const calcTaxes = (amount: number) => {
return Number((0.19 * amount).toFixed(2))
}
</script>
<template>
<Table class="relative document-table">
<TableHeader>
<TableRow class="hover:bg-transparent">
<TableHead class="sticky top-0 w-1/100 lg:w-1/100 hidden md:table-cell">Nr.</TableHead>
<TableHead class="sticky top-0 w-1/100 lg:w-1/20 text-center">Status</TableHead>
<TableHead class="sticky top-0 w-1/100 lg:w-1/20 lg:px-5 text-right hidden md:table-cell">Gestellt
</TableHead>
<TableHead class="sticky top-0 w-1/5 text-right lg:hidden">Rechnung</TableHead>
<TableHead class="sticky top-0 w-1/5 hidden lg:table-cell">Kunde</TableHead>
<TableHead colspan="2" class="sticky top-0 w-1/3 hidden lg:table-cell">Betreff</TableHead>
<TableHead class="sticky top-0 w-1/15 text-right">Netto</TableHead>
<TableHead class="sticky top-0 w-1/15 text-right hidden lg:table-cell">Ust.</TableHead>
<TableHead class="sticky top-0 w-1/15 text-right hidden lg:table-cell">Brutto</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.nr" @click="onItemClicked(invoice)"
class="select-none md:select-auto cursor-default bg-background"
:style="'color:' + statusBadgeTextColor(invoice.paymentStatus)">
<TableCell class="w-1/100 hidden md:table-cell lg:pr-5 tabular-nums">{{ invoice.nr }}
</TableCell>
<TableCell class="w-1/100 text-center">
<StatusBadge :variant="castToStatusVariant(invoice.paymentStatus)">{{
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
</TableCell>
<TableCell class="pr-5 hidden md:table-cell lg:px-5 text-right tabular-nums">{{
toLocalDate(invoice.invoiceDate) }}
</TableCell>
<TableCell class="lg:hidden max-w-[220px] md:max-w-[320px] overflow-hidden text-ellipsis">
<span class="font-bold">{{ invoice.customer?.companyName }}</span><br />
{{ invoice.title }}
</TableCell>
<TableCell
class="hidden lg:table-cell max-w-[100px] md:max-w-[120px] lg:max-w-auto overflow-hidden text-ellipsis font-bold">
{{ invoice.customer?.companyName }}</TableCell>
<TableCell colspan="2"
class="hidden lg:table-cell max-w-[120px] md:max-w-[160px] lg:max-w-auto overflow-hidden text-ellipsis">
{{
invoice.title }}</TableCell>
<TableCell class="text-right tabular-nums">{{ toCurrency(invoice.totalAmount) }}
</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell">{{
toCurrency(calcTaxes(invoice.totalAmount)) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell">{{
toCurrency(invoice.totalAmount + calcTaxes(invoice.totalAmount)) }}</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<!-- Summe -->
<TableRow
class="border-none bg-slate-50 dark:bg-neutral-900/60 hover:bg-slate-50 dark:hover:bg-neutral-900/60">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="3"></TableCell>
<TableCell class="py-4 text-right tabular-nums w-1/100 font-bold">Summe</TableCell>
<TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalAmount) }}</TableCell>
<TableCell class="py-4 text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTax) }}</TableCell>
<TableCell class="py-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGross) }}</TableCell>
</TableRow>
<!-- Bezahlt -->
<TableRow v-if="!(totalDue == 0 && totalNotIssued == 0)"
class="border-none bg-slate-50 dark:bg-neutral-900/60 hover:bg-slate-50 dark:hover:bg-neutral-900/60">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="3"></TableCell>
<TableCell class=" w-1/100 text-right tabular-nums font-bold">Bezahlt</TableCell>
<TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalPaid) }}</TableCell>
<TableCell class=" text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTaxPaid) }}
</TableCell>
<TableCell class=" text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossPaid)
}}</TableCell>
</TableRow>
<TableRow v-if="totalDue > 0"
class="border-none bg-slate-50 dark:bg-neutral-900/60 hover:bg-slate-50 dark:hover:bg-neutral-900/60 text-destructive">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="3"></TableCell>
<TableCell class="text-right tabular-nums w-1/100 font-bold">Offen</TableCell>
<TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalDue) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTaxDue) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossDue) }}</TableCell>
</TableRow>
<TableRow v-if="totalNotIssued > 0"
class="border-none bg-slate-50 dark:bg-neutral-900/60 hover:bg-slate-50 dark:hover:bg-neutral-900/60 text-muted-foreground">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="3"></TableCell>
<TableCell class="text-right tabular-nums w-1/100 font-bold">Nicht gestellt</TableCell>
<TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalNotIssued) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTaxNotIssued) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossNotIssued) }}</TableCell>
</TableRow>
</TableFooter>
</Table>
</template>
<style>
.document-table th,
.document-table td {
padding-top: 0.694em !important;
padding-bottom: 0.694em !important;
}
</style>
@@ -0,0 +1,610 @@
<!-- Rechnungsart: Wiederkehren, einmalig, Abschlag (siehe Chat, wirkt sich auf Leistungszeitraum aus) -->
<!-- TODO: Leistungsdatum -->
<!-- TODO: Leistungsstart -->
<!-- TODO: Leistungsende -->
<!-- TODO: Steuersatz in LineItem -->
<!-- TODO: Stunden und Tagessatz aus Settings -->
<!-- TODO: Client-side validation -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated, useTemplateRef } from "vue"
import { Customer, Invoice, Contact, PaymentTerms, Address } from "@/types"
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
import { toCurrency, toLocalDate, toShortISOString, cn, calcDueDate } from '@/lib/utils';
import axios from 'axios'
import { type DateValue, DateFormatter, getLocalTimeZone, parseDate, fromDate } from "@internationalized/date"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { StatusBadge, statusBadgeLabels, statusBadgeTextColor, StatusBadgeVariants } from '@/components/ui/status-badge'
import LineItemTable from '@/components/documents/LineItemTable.vue'
import { Eye, FileText, CircleEllipsis, Trash2, BookUser, User, CodeXml, CalendarIcon, MessageCircleQuestion, X, CircleX, Logs, ListCheck, ClipboardCheck, ClipboardList } from "lucide-vue-next"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { exportPdf, exportXml } from "@/routes/invoice";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'
const props = defineProps<{
invoiceData: Invoice | null,
customers: Customer[]
modelValue: boolean
}>()
const invoice = ref<Invoice | null>(props.invoiceData)
const paymentTermsData = ref([] as PaymentTerms[])
const isDirty = ref(false);
const isLoading = ref(false);
const importContact = ref(newContact() as Contact)
const importCustomer = ref(newCustomer() as Customer)
const alert = ref({ open: false, title: "", message: "", cancelText: "", onCancel: () => { }, confirmText: "", onConfirm: () => { } })
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
onMounted(async () => {
const response = await axios.get('/api/paymentterms')
paymentTermsData.value = response.data as PaymentTerms[]
})
const isOpen = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
}
})
const value = ref<DateValue>()
// watch for new external invoice data
watch(() => props.invoiceData as Invoice,
(newValue, oldValue) => {
if (newValue == oldValue) return
invoice.value = newValue
// Set initial state for a newly opened document
isDirty.value = false
isLoading.value = true
// console.log("invoiceData", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
}
)
// watch changes on local invoice date
watch(invoice,
(newValue, oldValue) => {
if (isLoading.value) {
// Initial load of invoice data
if (invoice.value && !invoice.value.billingData) {
// Set default billing data from customer
invoice.value.billingData = {
companyName: invoice.value.customer?.companyName || "",
vatId: invoice.value.customer?.vatId || "",
billingAddress: {
lineOne: invoice.value.customer?.billingAddress?.lineOne || "",
lineTwo: invoice.value.customer?.billingAddress?.lineTwo || "",
city: invoice.value.customer?.billingAddress?.city || "",
postalCode: invoice.value.customer?.billingAddress?.postalCode || "",
countryCode: invoice.value.customer?.billingAddress?.countryCode || "",
},
contactFirstName: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].firstName : "",
contactLastName: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].lastName : "",
paymentTerms: invoice.value.customer?.paymentTerms || paymentTermsData.value.length > 0 ? paymentTermsData.value[2] : null,
}
}
if (invoice.value && invoice.value.customer?.id !== 0) {
importCustomer.value = invoice.value.customer as Customer
// console.log("billingData contact", invoice.value?.billingData?.contactFirstName, invoice.value?.billingData?.contactLastName)
invoice.value.customer?.contacts.find(contact => {
if (
contact.firstName === invoice.value?.billingData?.contactFirstName &&
contact.lastName === invoice.value?.billingData?.contactLastName
) {
importContact.value = contact
return true
}
})
}
value.value = fromDate(new Date(invoice.value.invoiceDate), getLocalTimeZone())
}
else {
isDirty.value = true
}
// console.log("invoice", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
},
{ deep: true }
)
watch(importCustomer,
(newValue, oldValue) => {
if (!invoice.value) return
if (!isLoading.value) {
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
invoice.value.billingData.companyName = newValue.companyName
invoice.value.billingData.vatId = newValue.vatId
// Don't overwrite these values during loading
// they can intentionally be different from customer data
if (!invoice.value.billingData.billingAddress || !isLoading.value)
invoice.value.billingData.billingAddress = newValue.billingAddress as Address
if (!invoice.value.billingData.contactFirstName || !isLoading.value)
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
if (!invoice.value.billingData.contactLastName || !isLoading.value)
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
if (!invoice.value.billingData.paymentTerms || !isLoading.value)
invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
if (!isLoading.value) isDirty.value = true
}
},
{ deep: true }
)
watch(importContact,
(newValue, oldValue) => {
if (!invoice.value) return
if (!isLoading.value) {
if (newValue.id !== 0) {
invoice.value.billingData!.contactFirstName = newValue.firstName
invoice.value.billingData!.contactLastName = newValue.lastName
} else {
invoice.value.billingData!.contactFirstName = ''
invoice.value.billingData!.contactLastName = ''
}
isDirty.value = true;
}
},
{ deep: true }
)
onUpdated(() => {
isLoading.value = false;
// console.log("onUpdated", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
})
const saveChanges = () => {
if (invoice.value) {
emit('save', invoice.value)
isOpen.value = false
}
}
const cancelChanges = (event: Event | null) => {
if (isDirty.value) {
alert.value.title = "Wirklich schließen?"
alert.value.message = "Es gibt ungespeicherte Änderungen, die dann verloren gehen."
alert.value.cancelText = "Abbrechen"
alert.value.onCancel = () => {
event?.preventDefault()
event.returnValue = true
alert.value.open = false
}
alert.value.confirmText = "Schließen"
alert.value.onConfirm = () => {
emit('cancel')
isOpen.value = false
alert.value.open = false
}
alert.value.open = true
} else {
emit('cancel')
isOpen.value = false
}
}
const confirmCancel = () => {
return window.confirm('Es gibt ungespeicherte Änderungen. Möchtest Du die Seite wirklich verlassen?');
}
const preview = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id, '_blank')?.focus();
}
const downloadPdf = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/pdf');
}
const downloadXml = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/xml');
}
const deleteInvoice = function () {
let confirm = window.confirm('Möchtest Du diese Rechnung wirklich löschen?')
if (!confirm) return
emit('delete', invoice.value?.id)
isOpen.value = false
}
const updateTotalAmount = () => {
if (!invoice.value) return;
let total = 0;
invoice.value.items.forEach(item => {
total += item.quantity * item.price;
});
invoice.value.totalAmount = total;
}
</script>
<template>
<Dialog id="invoice-dialog" v-model:open="isOpen">
<DialogContent
class="sm:max-w-[min((100%-2rem),1152px)] grid-rows-[auto_minmax(0,1fr)_auto] p-0 h-[calc(100dvh-2rem)]"
@escapeKeyDown="cancelChanges" @interactOutside="cancelChanges">
<DialogHeader class="px-3 pt-3 flex flex-row justify-end">
<DialogTitle class="sr-only">Rechnung</DialogTitle>
<div v-if="invoice && invoice.id > 0" class="hidden md:flex mr-4">
<Button :size="'sm'" :variant="'ghost'" @click="preview">
<Eye :strokeWidth="1.666" class="text-current" />
<span>Vorschau</span>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button :size="'sm'" :variant="'ghost'" @click="downloadPdf">
<FileText :strokeWidth="1.666" class="text-current" />
<span>PDF</span>
</Button>
</TooltipTrigger>
<TooltipContent>
ZUGFeRD
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button :size="'sm'" :variant="'ghost'" @click="downloadXml">
<CodeXml :strokeWidth="1.666" class="text-current" />
<span>XML</span>
</Button>
</TooltipTrigger>
<TooltipContent>
XRechnung
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Sheet as-child class="relativ">
<SheetTrigger>
<Button :size="'sm'" :variant="'ghost'">
<ClipboardList :strokeWidth="1.666" class="text-current" />
<span>Audit</span>
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Are you absolutely sure?</SheetTitle>
<SheetDescription>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
<Button :size="'sm'" :variant="'ghost'" @click="deleteInvoice"
class="text-destructive hover:bg-destructive/5 hover:text-destructive">
<Trash2 :strokeWidth="1.666" class="text-current" />
<span>Löschen</span>
</Button>
</div>
<div class="md:hidden">
<DropdownMenu>
<DropdownMenuTrigger>
<Button :variant="'ghost'" :size="'lg'">
<CircleEllipsis class="size-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="mr-8">
<DropdownMenuItem class="flex justify-between" @click="preview">
<span class="mr-2">Vorschau</span>
<Eye :strokeWidth="1.666" class="text-current" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="flex justify-between" @click="exportPdf">
<div class="mr-2 flex flex-col">
<span>PDF exportieren</span>
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
</div>
<FileText :strokeWidth="1.666" class="text-current" />
</DropdownMenuItem>
<DropdownMenuItem class="flex justify-between" @click="exportXml">
<div class="mr-2 flex flex-col">
<span>XML exportieren</span>
<span class="text-xs text-muted-foreground">(XRechnung)</span>
</div>
<CodeXml :strokeWidth="1.666" class="text-current" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="flex justify-between text-destructive">
<span class="mr-2">Löschen</span>
<Trash2 :strokeWidth="1.666" class="text-current" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DialogHeader>
<div class="overflow-y-auto px-6" v-if="invoice">
<div id="document">
<div id="document-header"
class="block sticky top-0 py-4 bg-slate-100 bg-white dark:bg-neutral-800 z-1 flex items-end gap-12">
<div class="grow">
<h1 class="text-xl text-primary-foreground font-bold" v-if="invoice.id > 0">
Rechnung {{ invoice.nr
}}</h1>
<h1 class="text-xl text-primary-foreground font-bold" v-else>Neue
Rechnung
</h1>
<Input
class="md:text-xl px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
type="text" v-model="invoice.title" placeholder="Titel" />
</div>
<div class="flex flex-col m-0 items-end gap-0">
<span class="text-md text-muted-foreground">{{ toCurrency(invoice.totalAmount) }}</span>
<span class="text-2xl font-bold">{{ toCurrency(Number((invoice.totalAmount *
1.19).toFixed(2))) }}</span>
</div>
</div>
<div id="document-meta"
class="flex-none md:flex gap-12 mt-6 2 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg">
<div class="flex flex-col gap-0">
<div class="flex flex-row gap-4">
<Input type="text" v-model="invoice.billingData.companyName" placeholder="Firma"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Select v-model="importCustomer" by="id">
<SelectTrigger
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 border-none">
<SelectValue>
<BookUser />
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem :value="newCustomer()">
Manuell eingeben
</SelectItem>
<SelectItem v-for="customer in customers" :value="customer">
{{ customer.companyName }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-row gap-4">
<Input type="text" v-model="invoice.billingData.contactFirstName" placeholder="Vorname"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Input type="text" v-model="invoice.billingData.contactLastName" placeholder="Nachname"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Select v-model="importContact" by="id">
<SelectTrigger v-bind:disabled="!importCustomer || invoice.customer.id === 0"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 border-none">
<SelectValue>
<User />
</SelectValue>
</SelectTrigger>
<SelectContent v-if="invoice.customer && invoice.customer.id !== 0">
<SelectItem :value="{ id: 0, firstName: '', lastName: '' }">
</SelectItem>
<SelectItem v-for="customer in importCustomer.contacts" :value="customer">
{{ customer.firstName + ' ' + customer.lastName }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Input type="text" v-model="invoice.billingData.billingAddress.lineOne"
placeholder="Zeile 1"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Input type="text" v-model="invoice.billingData.billingAddress.lineTwo"
placeholder="Zeile 2"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<div class="flex gap-4">
<Input type="text" v-model="invoice.billingData.billingAddress.postalCode"
placeholder="PLZ"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 w-20 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Input type="text" v-model="invoice.billingData.billingAddress.city" placeholder="Stadt"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
</div>
<Input type="text" v-model="invoice.billingData.vatId" placeholder="USt-Id-Nr."
class="mt-6 bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
</div>
<div>
<Table>
<TableBody>
<!-- Status -->
<TableRow>
<TableHead class="w-1">Status</TableHead>
<TableCell class="flex items-center gap-4">
<StatusBadge size="sm" :variant="invoice.paymentStatus">{{
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
<Select v-model="invoice.paymentStatus">
<SelectTrigger
class="w-full bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-0 h-8!">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(label, value) in statusBadgeLabels"
:value="value">
<SelectLabel>{{ label }}</SelectLabel>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button v-if="['due', 'reminded'].includes(invoice.paymentStatus)"
:size="'sm'" :variant="'destructive'">Mahnen</Button>
</TableCell>
</TableRow>
<!-- Rechnungsdatum -->
<TableRow>
<TableHead>Datum</TableHead>
<TableCell>
<Input type="date" :modelValue="toShortISOString(invoice.invoiceDate)"
@update:modelValue="newValue => { invoice.invoiceDate = new Date(newValue); invoice.dueDate = calcDueDate(invoice.invoiceDate, invoice.billingData?.paymentTerms?.days) }"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none" />
</TableCell>
</TableRow>
<!-- Zahlungsziel -->
<TableRow>
<TableHead class="flex items-center gap-1">
Zahlungsziel
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageCircleQuestion strokeWidth="1.5"
class="h-[16px] w-[16px] ml-[1px] mb-2" />
</TooltipTrigger>
<TooltipContent>
Zahlungsbedingungen können<br />
beim Kunden hinterlegt werden.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
<TableCell>
<Select v-model="invoice.billingData.paymentTerms" by="id">
<SelectTrigger
class="w-full bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none">
<SelectValue placeholder="Zahlungsziel" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="term in paymentTermsData" :value="term">
<span v-if="term.isFixed">{{ term.description }}</span>
<span v-else>{{ term.days }} Tage</span>
</SelectItem>
</SelectContent>
</Select>
</TableCell>
</TableRow>
<!-- Fällig -->
<TableRow v-if="!invoice.billingData?.paymentTerms?.isFixed">
<TableHead>Fällig</TableHead>
<TableCell class="pl-5">{{ toLocalDate(invoice.dueDate) }}</TableCell>
</TableRow>
<!-- Leistungszeitraum -->
<TableRow>
<TableHead>Leistungszeitraum</TableHead>
<TableCell>
<Input type="date" :modelValue="toShortISOString(invoice.serviceStartDate)"
@update:modelValue="newValue => invoice.serviceStartDate = new Date(newValue)"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none w-fit inline" />
bis
<Input type="date" :modelValue="toShortISOString(invoice.serviceEndDate)"
@update:modelValue="newValue => invoice.serviceEndDate = new Date(newValue)"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none w-fit inline" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<LineItemTable :lineItems="invoice.items" @update:lineItems="updateTotalAmount" />
</div>
</div>
<DialogFooter class="p-6 pt-0 flex-row">
<div :size="'sm'" class="hidden md:block flex-grow-4"></div>
<Button class="grow md:grow-0" @click="cancelChanges">
Abbrechen
</Button>
<Button class="grow md:grow-0" :variant="'action'" @click="saveChanges"
:disabled="!isDirty">Speichern</Button>
</DialogFooter>
<AlertDialog v-model:open="alert.open" :asChild="true">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alert.title }}</AlertDialogTitle>
<AlertDialogDescription>
{{ alert.message }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button v-if="alert.onCancel" @click="alert.onCancel">{{ alert.cancelText }}</Button>
<Button variant="destructive" v-if="alert.onConfirm" @click="alert.onConfirm">{{
alert.confirmText
}}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
</template>
<style>
/* Remove close X */
[data-slot=dialog-content] button.ring-offset-background {
/* display: none; */
border-radius: 100%;
position: absolute;
left: 1rem;
width: 1rem;
height: 1rem;
color: var(--color-destructive);
}
/* Backdrop */
[data-slot=dialog-overlay] {
backdrop-filter: blur(var(--blur-sm));
}
</style>
@@ -0,0 +1,202 @@
<!-- TODO: Mengenfeld Komma als decimal point -->
<!-- Enter in LineItem = neue Zeile -->
<script setup lang="ts">
import { ref, watch, HTMLAttributes } from 'vue'
import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils';
import { LineItem } from '@/types';
import { newLineItem } from '@/types/index.d'
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
import { Input } from '@/components/ui/input';
import { CirclePlus, GripVertical, Trash2, Plus, TextSelect } from 'lucide-vue-next';
import Textarea from '../ui/textarea/Textarea.vue';
import Button from '../ui/button/Button.vue';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
import NumberInput from '../ui/number-input/NumberInput.vue';
import { GrowingTextarea } from '../ui/growing-textarea';
const props = defineProps<{
lineItems: LineItem[],
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
(e: 'update:lineItems', value: LineItem[]): void
}>()
const units = ref(['Stück', 'Stunden', 'Tage', 'pauschal'])
const items = ref(props.lineItems.sort(function (a, b) { return a.position - b.position })) // items only uses props.lineItems as the initial value;
watch(items, (newItems) => {
emit('update:lineItems', newItems)
}, { deep: true })
const newItem = () => {
const position = items.value.length + 1
let item = newLineItem()
item.position = position
items.value.push(item)
}
const deleteItem = (lineItem: LineItem) => {
const index = items.value.indexOf(lineItem)
if (index > -1) {
items.value.splice(index, 1)
}
recalculatePositions()
}
const recalculatePositions = () => {
for (let i = 0; i < items.value.length; i++) {
items.value[i].position = i + 1
}
}
</script>
<template>
<div :class="cn(props.class)">
<div class="backdrop-blur mt-8 bg-background/80 sticky top-[100px] z-1">
<Table class="table-fixed">
<TableHeader>
<TableRow class="hover:bg-transparent dark:hover:bg-transparent border-b-1">
<TableHead class="w-6 px-0"></TableHead>
<TableHead class="w-8 px-0 text-center">Pos.</TableHead>
<TableHead>Posten</TableHead>
<TableHead class="w-1/8">Einh.</TableHead>
<TableHead class="w-20 text-center">Menge</TableHead>
<TableHead class="w-1/8 text-right pr-5">Einzel</TableHead>
<TableHead class="w-1/8 text-right">Total</TableHead>
<TableHead class="w-16"></TableHead>
</TableRow>
</TableHeader>
</Table>
</div>
<Table v-if="items.length > 0" class="table-fixed">
<draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost"
@end="recalculatePositions">
<template #item="{ element }">
<TableRow>
<TableCell class="px-0 handle px-1 cursor-move w-6">
<GripVertical :size="18" :strokeWidth="1.5" class="text-muted-foreground" />
</TableCell>
<!-- Pos. -->
<TableCell class="text-center position w-8">{{ element.position }}</TableCell>
<!-- Posten -->
<TableCell>
<Input v-model="element.title" placeholder="Posten"
class="font-bold h-6 py-0 px-1 m-0 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none hover:border-1 dark:hover:border-1 placeholder:text-muted-foreground/30 shadow-none mb-1" />
<!-- <Textarea v-model="element.description" placeholder="Beschreibung"
class="py-0 min-h-4 px-1 m-0 bg-transparent dark:bg-transparent border-none placeholder:text-muted-foreground/30 shadow-none" /> -->
<GrowingTextarea v-model="element.description" placeholder="Beschreibung"
class="font-light bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 py-0 px-1 m-0 border-none shadow-none" />
</TableCell>
<!-- Einh. -->
<TableCell class="w-1/8 text-center">
<Select v-model="element.unit">
<SelectTrigger class="shadow-none bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none pr-0 py-0 pl-1 w-full h-6!">
<SelectValue placeholder="Einheit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="unit in units" :value="unit">
{{ unit }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<!-- Anz. -->
<TableCell class="w-20 text-center">
<NumberField v-model="element.quantity" :step="0.5" :format-options="{}">
<NumberFieldContent>
<NumberFieldDecrement class="text-muted-foreground p-1 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66" />
<NumberFieldInput class="h-6 border-none bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66" />
<NumberFieldIncrement class="text-muted-foreground p-1 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66" />
</NumberFieldContent>
</NumberField>
</TableCell>
<!-- Preis -->
<TableCell class="w-1/8 text-right tabular-nums">
<NumberInput :modelValue="Number(element.price)" class="bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 h-6 rounded" />
</TableCell>
<!-- Total -->
<TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity)
}}
</TableCell>
<!-- Buttons -->
<TableCell class="w-16 text-right">
<Button :variant="'ghost'" :size="'sm'" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 :size="18" />
</Button>
<Button :variant="'ghost'" :size="'sm'" @click="" class="has-[>svg]:px-1 text-muted-foreground">
<CirclePlus />
</Button>
</TableCell>
</TableRow>
</template>
</draggable>
<TableFooter class="bg-transparent">
<TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8" class="text-center">
<Button class="mt-2" variant="ghost" @click="newItem">
<Plus /> Neue Zeile
</Button>
</TableCell>
</TableRow>
</TableFooter>
</Table>
<Empty v-if="items.length < 1">
<EmptyHeader>
<EmptyMedia variant="icon">
<TextSelect class="text-muted-foreground" stroke-width="1.5" />
</EmptyMedia>
<EmptyTitle>Diese Rechnung ist leer</EmptyTitle>
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
</EmptyHeader>
<Button variant="action" @click="newItem">
<Plus /> Neue Zeile
</Button>
</Empty>
</div>
</template>
<style scoped>
tr.ghost {
background: var(--color-muted);
color: var(--color-muted-foreground);
}
tr.ghost .position {
color: transparent;
}
tr.ghost .handle svg {
stroke: transparent;
fill: transparent;
}
</style>