Two month of work

This commit is contained in:
2026-02-17 10:35:03 +01:00
parent 0ffbeeedff
commit d9fd3d1ccb
158 changed files with 5637 additions and 1512 deletions
@@ -4,7 +4,7 @@ import { computed } from 'vue'
import { Invoice } from '@/types'
import { toLocalDate, toCurrency, toFixedRounded } from '@/lib/utils'
import { StatusBadge, statusBadgeLabels, statusTextStyle, castToStatusVariant } from '@/components/ui/status-badge'
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/crm-table'
const props = defineProps<{
@@ -104,25 +104,23 @@ const calcTaxes = (amount: number) => {
<template>
<Table class="relative document-table bg-none">
<TableHeader>
<TableRow class="hover:bg-transparent border-none">
<Table class="relative document-table">
<TableHeader class="sticky top-0">
<TableRow>
<TableHead class="w-1/100 lg:w-1/100 hidden md:table-cell lg:pl-4 lg:pr-5">Nr.</TableHead>
<TableHead class="w-1/100 lg:w-1/20 text-center">Status</TableHead>
<TableHead class="w-1/100 lg:w-1/20 lg:px-5 text-right hidden md:table-cell">Gestellt
</TableHead>
<TableHead class="w-1/5 text-right lg:hidden">Rechnung</TableHead>
<TableHead class="w-1/100 lg:w-1/20 lg:px-5 hidden md:table-cell">Gestellt</TableHead>
<TableHead class="w-1/5 lg:hidden">Rechnung</TableHead>
<TableHead class="w-1/5 hidden lg:table-cell">Kunde</TableHead>
<TableHead colspan="2" class="w-1/3 hidden lg:table-cell">Betreff</TableHead>
<TableHead class="w-1/3 hidden lg:table-cell">Betreff</TableHead>
<TableHead class="w-1/12 text-right">Netto</TableHead>
<TableHead class="w-1/12 text-right hidden lg:table-cell">Ust.</TableHead>
<TableHead class="w-1/12 text-right hidden lg:table-cell lg:pr-4">Brutto</TableHead>
</TableRow>
</TableHeader>
<TableBody class="overflow-clip rounded-lg shadow">
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.nr" @click="onItemClicked(invoice)"
class="select-none md:select-auto cursor-default bg-background"
:class="statusTextStyle(invoice.paymentStatus)">
<TableCell class="w-1/100 hidden md:table-cell lg:pl-4 lg:pr-5 tabular-nums">{{ invoice.nr }}
</TableCell>
@@ -131,7 +129,7 @@ const calcTaxes = (amount: number) => {
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
</TableCell>
<TableCell class="pr-5 hidden md:table-cell lg:px-5 text-right tabular-nums">{{
<TableCell class="pr-5 hidden md:table-cell lg:px-5 tabular-nums whitespace-nowrap">{{
toLocalDate(invoice.invoiceDate) }}
</TableCell>
<TableCell class="lg:hidden max-w-[220px] md:max-w-[320px] overflow-hidden text-ellipsis">
@@ -141,7 +139,7 @@ const calcTaxes = (amount: number) => {
<TableCell
class="hidden lg:table-cell max-w-[100px] md:max-w-[120px] lg:max-w-auto overflow-hidden text-ellipsis font-semibold">
{{ invoice.billingData?.companyName }}</TableCell>
<TableCell colspan="2"
<TableCell
class="hidden lg:table-cell max-w-[120px] md:max-w-[160px] lg:max-w-auto overflow-hidden text-ellipsis">
{{
invoice.title }}</TableCell>
@@ -154,10 +152,10 @@ const calcTaxes = (amount: number) => {
</TableRow>
</TableBody>
<TableFooter class="border-none bg-transparent">
<TableFooter>
<!-- Summe -->
<TableRow class="border-none hover:bg-transparent">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableRow>
<TableCell class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell>
<TableCell class="py-4 text-right tabular-nums w-1/100 font-bold">Summe</TableCell>
@@ -168,8 +166,8 @@ const calcTaxes = (amount: number) => {
toCurrency(totalGross) }}</TableCell>
</TableRow>
<!-- Bezahlt -->
<TableRow v-if="!(totalDue == 0 && totalNotIssued == 0)" class="border-none hover:bg-transparent">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableRow v-if="!(totalDue == 0 && totalNotIssued == 0)">
<TableCell class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell>
<TableCell class=" w-1/100 text-right tabular-nums font-bold">Bezahlt</TableCell>
@@ -178,11 +176,11 @@ const calcTaxes = (amount: number) => {
</TableCell>
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGrossPaid)
}}</TableCell>
}}</TableCell>
</TableRow>
<TableRow v-if="totalDue > 0" class="border-none text-destructive hover:bg-transparent">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableRow v-if="totalDue > 0" class="text-destructive">
<TableCell class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell>
<TableCell class="text-right tabular-nums w-1/100 font-bold">Offen</TableCell>
@@ -191,10 +189,10 @@ const calcTaxes = (amount: number) => {
</TableCell>
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGrossDue)
}}</TableCell>
}}</TableCell>
</TableRow>
<TableRow v-if="totalNotIssued > 0" class="border-none text-muted-foreground hover:bg-transparent">
<TableRow v-if="totalNotIssued > 0">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell>
@@ -211,11 +209,6 @@ const calcTaxes = (amount: number) => {
</template>
<style>
.document-table {
font-size: 0.833rem;
}
.document-table th {}
.document-table td {
padding-top: 1.125em !important;
@@ -229,5 +222,5 @@ const calcTaxes = (amount: number) => {
.document-table tfoot td {
padding-top: 0.64em !important;
padding-bottom: 0.64em !important;
}
}
</style>
@@ -6,18 +6,16 @@
<!-- TODO: Steuersatz in LineItem -->
<!-- TODO: Stunden und Tagessatz aus Settings -->
<!-- TODO: Client-side validation -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated, toRaw } from "vue"
import { ref, computed, watch, onMounted, onUpdated, toRaw, nextTick } from "vue"
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus, Unit } from "@/types"
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils'
import axios from 'axios'
import { type DateValue, getLocalTimeZone, fromDate } from "@internationalized/date"
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded, hotkey } from '@/lib/utils'
import axios from "axios"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Table, TableBody, TableCell, TableHead, TableRow, } from '@/components/ui/table'
import { Table, TableBody, TableCell, TableHead, TableRow, } from '@/components/ui/crm-table'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'
import { Button } from '@/components/ui/crm-button'
import { Input } from '@/components/ui/crm-input';
@@ -28,35 +26,21 @@ import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion,
import { alertStore } from "@/stores/alertStore"
import { GrowingTextarea } from '../ui/growing-textarea'
import { toast } from "vue-sonner"
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import DialogClose from "../ui/dialog/DialogClose.vue"
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue";
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue"
import SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue"
const DEBUG = ref(false)
const props = defineProps<{
invoiceData?: Invoice,
invoiceData: Invoice | undefined,
modelValue: boolean
}>()
const invoice = ref<Invoice>()
const units = ref([] as Unit[])
const customers = ref([] as Customer[])
const paymentTermsData = ref([] as PaymentTerms[])
const isDirty = ref(false)
const isLoading = ref(false)
const isSaving = ref(false)
const itemsLoading = ref(false)
const importContact = ref(newContact() as Contact)
const importCustomer = ref(newCustomer() as Customer)
const alert = alertStore()
const reminderDialogOpen = ref(false)
const reminderLoading = ref(false)
const value = ref<DateValue>() // TODO: name properly
const fileInput = ref<HTMLInputElement | null>(null);
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
// Dialog state
const isOpen = computed({
get: () => props.modelValue,
set: (value) => {
@@ -64,7 +48,21 @@ const isOpen = computed({
}
})
const title = computed<string>(() => {
const invoice = ref<Invoice>()
const units = ref([] as Unit[])
const customers = ref([] as Customer[])
const paymentTerms = ref([] as PaymentTerms[])
const isDirty = ref(false)
const isSaving = ref(false)
const itemsLoading = ref(false)
const importContact = ref(newContact() as Contact)
const importCustomer = ref(newCustomer() as Customer)
const alert = alertStore()
const reminderDialogOpen = ref(false)
const reminderLoading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null);
const dataLoaded = ref(false)
const title = computed<string>(_ => {
if (invoice.value && invoice.value.id !== 0) {
return `Rechnung ${invoice.value.nr || ''}`
} else {
@@ -72,83 +70,99 @@ const title = computed<string>(() => {
}
})
onMounted(async () => {
// Load customers and payment terms
// Load additional data that wasnt provided by parent invoices view
try {
const promises = [];
promises.push(axios.get('/api/customers'))
promises.push(axios.get('/api/paymentterms'))
promises.push(axios.get('/api/units'))
const promises: Promise<any>[] = [];
// Create an array of promises for each data request
promises.push(axios.get("/api/customers"))
promises.push(axios.get("/api/paymentterms"))
promises.push(axios.get("/api/units"))
// Wait for all promises to resolve
const responses = await Promise.all(promises)
let responseIndex = 0
customers.value = responses[responseIndex].data as Customer[]
paymentTermsData.value = responses[responseIndex + 1].data as PaymentTerms[]
units.value = responses[responseIndex + 2].data as Unit[]
// Process each response
customers.value = responses[0].data
paymentTerms.value = responses[0].data
units.value = responses[0].data
} catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error))
}
// Register hotkeys
hotkey('mod+i', importLineItems, null, () => isOpen.value)
hotkey('mod+e', exportPdf, null, () => isOpen.value)
hotkey('mod+p', preview, null, () => isOpen.value)
})
onUpdated(() => {
if (isLoading.value) isLoading.value = false
// console.group('onUpdated')
// console.error(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
})
// Initial data from parent view
watch(() => props.invoiceData, async () => {
// Reset state flag
isDirty.value = false
dataLoaded.value = false
watch(() => props.modelValue, (open) => {
// on open
if (open) {
// console.group('on open')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (DEBUG.value) {
console.group('on parent data')
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
}
// Reset state flags
isDirty.value = false;
isLoading.value = true;
// Get invoice data from props
invoice.value = props.invoiceData
// Get invoice data from props
// console.warn('trigger invoice watcher')
invoice.value = props.invoiceData
// Load line items
if (invoice.value && invoice.value.id !== 0) {
itemsLoading.value = true
// Load line items
if (invoice.value && invoice.value.id !== 0) {
itemsLoading.value = true
try {
itemsLoading.value = true
axios.get('/api/lineitems/' + invoice.value.id).then(response => {
if (invoice.value) invoice.value.items = response.data as LineItem[]
})
} catch (error) {
toast.error('Fehler beim Laden der Positionen', error || String(error))
}
} else {
try {
await axios.get('/api/lineitems/' + invoice.value.id).then(response => {
if (invoice.value) invoice.value.items = response.data as LineItem[]
})
} catch (error) {
toast.error('Fehler beim Laden der Positionen', error || String(error))
} finally {
await nextTick()
itemsLoading.value = false
}
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
/* wait until next tick to reset loading state
*
* as changes made to customer and contacts here will probably
* change the invoice and would then trigger the invoice watcher agai
*/
await nextTick()
dataLoaded.value = true
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
console.groupEnd()
}
})
// watch changes on local invoice date
watch(invoice,
(newValue, oldValue) => {
async (newValue, oldValue) => {
if (newValue == oldValue) return
// console.group('watch invoice')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (DEBUG.value) {
console.group('watch invoice')
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
}
if (isLoading.value) {
if (dataLoaded.value) {
isDirty.value = true
} else {
if (!newValue) {
// console.groupEnd()
console.groupEnd()
return;
}
// Set default billing data from customer
// If no billing data is store in the invoice, generat ot from customer
if (!newValue.billingData) {
// console.warn('trigger invoice watcher')
newValue.billingData = {
companyName: newValue.customer?.companyName || "",
vatId: newValue.customer?.vatId || "",
@@ -167,8 +181,6 @@ watch(invoice,
}
if (newValue.customerId && newValue.customerId !== 0) {
// if (importCustomer.value != newValue.customer)
// console.warn('trigger importCustomer watcher')
customers.value.find(customer => {
if (customer.id === newValue.customerId) {
if (invoice.value) invoice.value.customer = customer as Customer
@@ -177,8 +189,6 @@ watch(invoice,
customer.contacts.find(contact => {
if (contact.firstName === newValue?.billingData?.contactFirstName &&
contact.lastName === newValue?.billingData?.contactLastName) {
// if (importContact.value != contact)
// console.warn('trigger importContact watcher')
importContact.value = contact
return true
}
@@ -186,15 +196,12 @@ watch(invoice,
}
})
}
value.value = fromDate(new Date(newValue.invoiceDate), getLocalTimeZone())
}
else {
isDirty.value = true
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
console.groupEnd()
}
},
{ deep: true }
)
@@ -204,13 +211,14 @@ watch(importCustomer,
if (newValue == oldValue) return
if (!invoice.value) return
// console.group('watch importCustomer')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (DEBUG.value) {
console.group('watch importCustomer')
console.log(`isDirty: ${isDirty.value}`)
}
// Don't overwrite these values during loading
// they can intentionally be different from customer data
if (!isLoading.value) {
if (dataLoaded.value) {
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
// console.warn('trigger invoice watcher')
@@ -232,8 +240,10 @@ watch(importCustomer,
isDirty.value = true;
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`)
console.groupEnd()
}
},
{ deep: true }
)
@@ -243,10 +253,12 @@ watch(importContact,
if (newValue == oldValue) return
if (!invoice.value) return
// console.group('watch importContact')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (DEBUG.value) {
console.group('watch importContact')
console.log(`isDirty: ${isDirty.value}`)
}
if (!isLoading.value) {
if (dataLoaded.value) {
if (newValue.id !== 0) {
invoice.value.billingData!.contactFirstName = newValue.firstName
invoice.value.billingData!.contactLastName = newValue.lastName
@@ -258,8 +270,10 @@ watch(importContact,
isDirty.value = true;
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`)
console.groupEnd()
}
},
{ deep: true }
)
@@ -278,7 +292,7 @@ const save = async () => {
isSaving.value = true
try {
// Prepare the invoice data for API request
// Prepare the invoice data API request
const invoiceToSave = {
nr: invoice.value.nr,
invoiceDate: invoice.value.invoiceDate,
@@ -322,11 +336,10 @@ const save = async () => {
invoice.value = response.data;
} else {
// Update existing invoice
const response = await axios.put(`/api/invoices/${invoice.value.id}`, invoiceToSave);
await axios.put(`/api/invoices/${invoice.value.id}`, invoiceToSave);
}
emit('save', invoice.value)
// isOpen.value = false
} catch (error) {
toast.error("Rechnung konnte nicht gespeichert werden", {
description: (error as Error).message,
@@ -334,7 +347,7 @@ const save = async () => {
} finally {
// remove spinner from save button
isSaving.value = false
setTimeout(() => { isDirty.value = false }, 1000)
isDirty.value = false
}
}
@@ -369,12 +382,12 @@ const preview = function () {
window?.open('/invoice/' + invoice.value.id, '_blank')?.focus();
}
const downloadPdf = function () {
const exportPdf = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/pdf');
}
const downloadXml = function () {
const exportXml = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/xml');
}
@@ -444,11 +457,11 @@ const sendReminder = async function (to: string | undefined, cc: string | undefi
}
const updateLineItems = (newItems: LineItem[]) => {
if (isLoading.value) return;
if (!dataLoaded.value) return;
if (!invoice.value) return;
// console.group('updateLineItems');
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`);
console.group('updateLineItems');
console.log(`isDirty: ${isDirty.value}`);
// Konvertiere die neuen Items in normale Objekte
const rawItems = toRaw(newItems) || [];
@@ -456,13 +469,13 @@ const updateLineItems = (newItems: LineItem[]) => {
// Sortiere die Items nach position
const sortedItems = [...rawItems].sort((a, b) => a.position - b.position);
// Erstellen Sie eine tiefe Kopie der neuen Items
// Create a deep copy of the new items
const updatedItems = JSON.parse(JSON.stringify(sortedItems));
// Aktualisieren Sie die Items in der Rechnung
// Update the invoice items
invoice.value.items = updatedItems;
// Berechnen Sie den neuen Gesamtbetrag
// Calculate the new total amount
let total = 0;
updatedItems.forEach(item => {
total += item.quantity * item.price;
@@ -472,11 +485,13 @@ const updateLineItems = (newItems: LineItem[]) => {
// Erzwingen Sie eine Aktualisierung der Benutzeroberfläche
invoice.value = { ...invoice.value };
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`);
// console.groupEnd();
console.log(`isDirty: ${isDirty.value}`);
console.groupEnd();
}
const importLineItems = () => {
if (!isOpen) return
if (!fileInput.value) {
fileInput.value = document.createElement('input');
fileInput.value.type = 'file';
@@ -495,18 +510,14 @@ const handleFileUpload = async (event: Event) => {
formData.append('csv', file);
try {
if (!invoice.value || !invoice.value.id) {
throw new Error('Rechnung muss gespeichert werden, bevor Positionen importiert werden können');
}
const response = await axios.post(`/api/lineitems/import/${invoice.value.id}`, formData, {
const response = await axios.post(`/api/lineitems/import`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (invoice.value) {
invoice.value.items = response.data;
invoice.value.items = invoice.value.items.concat(response.data as LineItem[]);
isDirty.value = true;
}
@@ -539,7 +550,7 @@ const handleFileUpload = async (event: Event) => {
<h1>{{ title }}</h1>
</DialogTitle>
<DialogDescription>
<Input v-if="invoice" v-model="invoice.title"
<Input v-if="invoice" v-model="invoice.title" :id="'invoice-title'"
class="text-foreground md:text-base text-ellipsis px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
type="text" placeholder="Titel" />
</DialogDescription>
@@ -550,8 +561,8 @@ const handleFileUpload = async (event: Event) => {
<!-- Save -->
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="save"
:disabled="isSaving">
<Loader2 v-if="isSaving" stroke-width="1.5" class="animate-spin" />
<Check v-else stroke-width="1.5" />
<Loader2 v-if="isSaving" class="animate-spin" />
<Check v-else />
Speichern
</Button>
@@ -573,7 +584,7 @@ const handleFileUpload = async (event: Event) => {
<Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
<TooltipTrigger>
<Button size="sm" variant="success" @click="updateStatus('paid')">
<FileCheck stroke-width="1.5" /> Bezahlt
<FileCheck /> Bezahlt
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -587,7 +598,6 @@ const handleFileUpload = async (event: Event) => {
<Button size="sm" variant="destructive" @click="openReminderDialog"
:disabled="reminderLoading" class="gap-0">
<Loader2 class="h-4 w-4 transition-[width] ease-in-out animate-spin"
stroke-width="1.5"
:class="{ 'w-0!': !reminderLoading, 'mr-2': reminderLoading }" />
Erinnern
</Button>
@@ -599,10 +609,10 @@ const handleFileUpload = async (event: Event) => {
<!-- Ellipsis menu -->
<DropdownMenu v-if="invoice && invoice.id > 0">
<DropdownMenu v-if="invoice">
<DropdownMenuTrigger>
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
<Ellipsis class="size-4" stroke-width="1.5" />
<Ellipsis class="size-4" />
</Button>
</DropdownMenuTrigger>
@@ -612,7 +622,7 @@ const handleFileUpload = async (event: Event) => {
<!-- Import Items -->
<DropdownMenuItem class="flex items-center justify-between" @click="importLineItems">
<div class="flex items-center gap-3">
<Import :strokeWidth="1.5" class="text-muted-foreground" />
<Import class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>Posten importieren</span>
<span class="text-xs text-muted-foreground">(CSV)</span>
@@ -628,10 +638,9 @@ const handleFileUpload = async (event: Event) => {
<DropdownMenuSeparator />
<!-- Preview -->
<DropdownMenuItem v-if="invoice?.paymentStatus == 'draft'"
class="flex items-center justify-between" @click="preview">
<DropdownMenuItem class="flex items-center justify-between" @click="preview">
<div class="flex items-center gap-3">
<Eye :strokeWidth="1.5" class="text-muted-foreground" />
<Eye class="text-muted-foreground" />
<span class="mr-4">Vorschau</span>
</div>
<KbdGroup>
@@ -643,9 +652,9 @@ const handleFileUpload = async (event: Event) => {
<!-- PDF -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
class="flex justify-between" @click="downloadPdf">
class="flex justify-between" @click="exportPdf">
<div class="flex items-center gap-3">
<FileText stroke-width="1.5" class="text-muted-foreground" />
<FileText class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>PDF exportieren</span>
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
@@ -660,9 +669,9 @@ const handleFileUpload = async (event: Event) => {
<!-- XML -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
class="flex justify-between" @click="downloadXml">
class="flex justify-between" @click="exportXml">
<div class="flex items-center gap-3">
<CodeXml stroke-width="1.5" class="text-muted-foreground" />
<CodeXml class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>XML exportieren</span>
<span class="text-xs text-muted-foreground">(XRechnung)</span>
@@ -676,7 +685,7 @@ const handleFileUpload = async (event: Event) => {
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
class="flex justify-between" @click="" disabled>
<div class="flex items-center gap-3">
<Logs stroke-width="1.5" class="text-muted-foreground" />
<Logs class="text-muted-foreground" />
<span>Audit</span>
</div>
</DropdownMenuItem>
@@ -688,8 +697,8 @@ const handleFileUpload = async (event: Event) => {
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
class="flex justify-between" @click="updateStatus('cancelled')">
<div class="flex items-center gap-3">
<!-- <FileX stroke-width="1.5" class="text-muted-foreground"/> -->
<Ban :strokeWidth="1.5" class="text-current" />
<!-- <FileX class="text-muted-foreground"/> -->
<Ban class="text-current" />
<span class="mr-2">Stornieren</span>
</div>
</DropdownMenuItem>
@@ -699,7 +708,7 @@ const handleFileUpload = async (event: Event) => {
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
@click="deleteInvoice">
<div class="flex items-center gap-3">
<Trash2 :strokeWidth="1.5" class="text-current" />
<Trash2 class="text-current" />
<span class="mr-2">Löschen</span>
</div>
</DropdownMenuItem>
@@ -832,7 +841,7 @@ const handleFileUpload = async (event: Event) => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageCircleQuestion strokeWidth="1.5"
<MessageCircleQuestion
class="h-[16px] w-[16px] ml-[1px] mb-2" />
</TooltipTrigger>
<TooltipContent>
@@ -851,7 +860,7 @@ const handleFileUpload = async (event: Event) => {
<SelectValue placeholder="Zahlungsziel" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="term in paymentTermsData" :value="term">
<SelectItem v-for="term in paymentTerms" :value="term">
<span v-if="term.isFixed">{{ term.description }}</span>
<span v-else>{{ term.days }} Tage</span>
</SelectItem>
@@ -7,20 +7,21 @@ import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils';
import { LineItem, Product, Unit } from '@/types';
import { newLineItem, newUnit } from '@/types/index.d'
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/crm-table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
import { Input } from '@/components/ui/crm-input';
import { Loader2, GripVertical, Trash2, Plus, TextSelect, CheckIcon, ChevronsUpDownIcon } from 'lucide-vue-next';
import { Button } from '@/components/ui/crm-button';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
import NumberInput from '@/components/ui/number-input/NumberInput.vue';
import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue';
import { GrowingTextarea } from '@/components/ui/growing-textarea';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'
import axios, { AxiosError } from "axios";
import { toast } from "vue-sonner";
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
const DEBUG = ref(true)
@@ -194,7 +195,7 @@ const recalculatePositions = () => {
<TableRow v-if="element.isSection">
<TableCell class="handle px-1 cursor-move w-6">
<GripVertical :size="18" stroke-width="1.5" class="text-muted-foreground" />
<GripVertical :size="18" class="text-muted-foreground" />
</TableCell>
<!-- Title -->
@@ -210,7 +211,7 @@ const recalculatePositions = () => {
<TableCell class="w-8 text-right px-1">
<Button variant="ghost" size="sm" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 :size="18" stroke-width="1.5" />
<Trash2 :size="18" />
</Button>
</TableCell>
</TableRow>
@@ -218,7 +219,7 @@ const recalculatePositions = () => {
<TableRow v-else>
<TableCell class="handle px-1 cursor-move w-6">
<GripVertical :size="18" stroke-width="1.5" class="text-muted-foreground" />
<GripVertical :size="18" class="text-muted-foreground" />
</TableCell>
<!-- Pos. -->
@@ -272,19 +273,19 @@ const recalculatePositions = () => {
<!-- Total -->
<TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity)
}}
}}
</TableCell>
<!-- Buttons -->
<TableCell class="w-8 text-right px-1">
<Button variant="ghost" size="sm" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 stroke-width="1.5" />
<Trash2 />
</Button>
<!-- <Button variant="ghost" size="sm" @click=""
class="has-[>svg]:px-1 text-muted-foreground">
<BetweenHorizonalEnd stroke-width="1.5"/>
<BetweenHorizonalEnd />
</Button> -->
</TableCell>
@@ -299,18 +300,18 @@ const recalculatePositions = () => {
<div class="flex gap-2 justify-center">
<Button class="mt-4" variant="ghost" @click="newItem(true)">
<Plus stroke-width="1.5" /> Neuer Abschnitt
<Plus /> Neuer Abschnitt
</Button>
<Button class="mt-4" variant="ghost" @click="newItem(false)">
<Plus stroke-width="1.5" /> Neue Zeile
<Plus /> Neue Zeile
</Button>
<Popover v-model:open="productsComboboxOpen">
<PopoverTrigger as-child>
<Button variant="ghost" role="combobox" :aria-expanded="productsComboboxOpen" class="mt-4"
:disabled="!products || products.length === 0">
<Plus stroke-width="1.5" />
<Plus />
Produkt hinzufügen
</Button>
</PopoverTrigger>
@@ -326,7 +327,7 @@ const recalculatePositions = () => {
}" class="hover:bg-accent">
<!-- Thumbnail -->
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-lg">
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-sm shrink-0">
<img v-if="product.image" :src="'storage/uploads/products/' + product.image"
class="size-full object-cover dark:brightness-75" loading="lazy" />
<PlaceholderPattern v-else />
@@ -348,59 +349,63 @@ const recalculatePositions = () => {
</Table>
<Loader2 v-if="props.isLoading" class="mx-auto mt-8 h-6 w-6 animate-spin text-muted-foreground"
stroke-width="1.5" />
/>
<Empty v-else-if="items.length < 1" class="py-8!">
<EmptyHeader>
<EmptyMedia variant="icon">
<TextSelect class="text-muted-foreground" stroke-width="1.5" />
<TextSelect class="text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Diese Rechnung ist leer</EmptyTitle>
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
</EmptyHeader>
<div class="flex gap-2">
<Button variant="action" @click="newItem(true)">
<Plus stroke-width="1.5" /> Neuer Abschnitt
</Button>
<ButtonGroup>
<Button variant="action" @click="newItem(false)">
<Plus stroke-width="1.5" /> Neue Zeile
</Button>
<Button @click="newItem(true)">
<Plus /> Neuer Abschnitt
</Button>
<Popover v-model:open="productsComboboxOpen">
<PopoverTrigger as-child>
<Button variant="action" role="combobox" :aria-expanded="productsComboboxOpen"
:disabled="!products || products.length === 0">
<Plus stroke-width="1.5" />
Produkt hinzufügen
</Button>
</PopoverTrigger>
<PopoverContent class="w-50 p-0">
<Command>
<CommandInput placeholder="Produkt suchen…" />
<CommandList>
<CommandEmpty>Kein Produkt gefunden</CommandEmpty>
<CommandGroup>
<CommandItem v-for="product in products" :key="product.id" :value="product.id" @select="() => {
productsComboboxOpen = false
insertProduct(product)
}" class="hover:bg-accent">
<Button @click="newItem(false)">
<Plus /> Neue Zeile
</Button>
<!-- Thumbnail -->
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-lg">
<img v-if="product.image" :src="'storage/uploads/products/' + product.image"
class="size-full object-cover dark:brightness-75" loading="lazy" />
<PlaceholderPattern v-else />
</div>
<Popover v-model:open="productsComboboxOpen">
<PopoverTrigger as-child>
<Button role="combobox" :aria-expanded="productsComboboxOpen"
:disabled="!products || products.length === 0">
<Plus />
Produkt hinzufügen
</Button>
</PopoverTrigger>
<PopoverContent class="w-50 p-0">
<Command>
<CommandInput placeholder="Produkt suchen…" />
<CommandList>
<CommandEmpty>Kein Produkt gefunden</CommandEmpty>
<CommandGroup>
<CommandItem v-for="product in products" :key="product.id" :value="product.id" @select="() => {
productsComboboxOpen = false
insertProduct(product)
}" class="hover:bg-accent">
{{ product.title }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Thumbnail -->
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-sm shrink-0">
<img v-if="product.image" :src="'storage/uploads/products/' + product.image"
class="size-full object-cover dark:brightness-75" loading="lazy" />
<PlaceholderPattern v-else />
</div>
{{ product.title }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</ButtonGroup>
</div>