Add LineItem CSV import and fix Unit API

This commit is contained in:
2025-12-08 13:20:52 +01:00
parent ee6525b549
commit 7ddf1337c1
12 changed files with 437 additions and 59 deletions
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { type Invoice } from '@/types'
import { Invoice } from '@/types'
import { toLocalDate, toCurrency, toFixedRounded } from '@/lib/utils'
import { StatusBadge, statusBadgeLabels, statusTextStyle, castToStatusVariant } from '@/components/ui/status-badge'
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
const props = defineProps<{
@@ -176,8 +176,9 @@ const calcTaxes = (amount: number) => {
<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="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossPaid)
}}</TableCell>
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGrossPaid)
}}</TableCell>
</TableRow>
<TableRow v-if="totalDue > 0" class="border-none text-destructive hover:bg-transparent">
@@ -188,7 +189,8 @@ const calcTaxes = (amount: number) => {
<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="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossDue)
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGrossDue)
}}</TableCell>
</TableRow>
@@ -213,9 +215,8 @@ const calcTaxes = (amount: number) => {
font-size: 0.833rem;
}
.document-table th {
.document-table th {}
}
.document-table td {
padding-top: 1.125em !important;
padding-bottom: 1.125em !important;
@@ -10,7 +10,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated, toRaw } from "vue"
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus } from "@/types"
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'
@@ -24,7 +24,7 @@ import { Input } from '@/components/ui/crm-input';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { StatusBadge, statusBadgeLabels } from '@/components/ui/status-badge'
import LineItemTable from '@/components/documents/LineItemTable.vue'
import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, Ban, Logs } from "lucide-vue-next"
import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, Ban, Logs, Import } from "lucide-vue-next"
import { alertStore } from "@/stores/alertStore"
import { GrowingTextarea } from '../ui/growing-textarea'
import { toast } from "vue-sonner"
@@ -38,7 +38,9 @@ const props = defineProps<{
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)
@@ -51,6 +53,8 @@ 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'])
const isOpen = computed({
@@ -74,10 +78,12 @@ onMounted(async () => {
const promises = [];
promises.push(axios.get('/api/customers'))
promises.push(axios.get('/api/paymentterms'))
promises.push(axios.get('/api/units'))
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[]
} catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error))
}
@@ -211,14 +217,14 @@ watch(importCustomer,
invoice.value.billingData.companyName = newValue.companyName
invoice.value.billingData.vatId = newValue.vatId
if (!invoice.value.billingData.billingAddress)
invoice.value.billingData.billingAddress = newValue.billingAddress as Address
if (!invoice.value.billingData.contactFirstName)
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
if (!invoice.value.billingData.contactLastName)
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
if (!invoice.value.billingData.paymentTerms)
invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
// if (!invoice.value.billingData.billingAddress)
invoice.value.billingData.billingAddress = newValue.billingAddress as Address
// if (!invoice.value.billingData.contactFirstName)
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
// if (!invoice.value.billingData.contactLastName)
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
// if (!invoice.value.billingData.paymentTerms)
invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
// console.warn('trigger invoice watcher')
invoice.value.customer = newValue
@@ -305,7 +311,7 @@ const save = async () => {
title: item.title,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unitId: item.unitId,
price: item.price
}))
}
@@ -470,6 +476,54 @@ const updateLineItems = (newItems: LineItem[]) => {
// console.groupEnd();
}
const importLineItems = () => {
if (!fileInput.value) {
fileInput.value = document.createElement('input');
fileInput.value.type = 'file';
fileInput.value.accept = '.csv';
fileInput.value.addEventListener('change', handleFileUpload);
}
fileInput.value.click();
}
const handleFileUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const formData = new FormData();
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, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (invoice.value) {
invoice.value.items = response.data;
isDirty.value = true;
}
toast.success('Positionen erfolgreich importiert');
} catch (error) {
console.error('Fehler beim Importieren der Positionen:', error);
toast.error('Fehler beim Importieren der Positionen', {
description: error instanceof Error ? error.message : String(error)
});
} finally {
// Reset file input
if (fileInput.value) {
fileInput.value.value = '';
}
}
}
</script>
<template>
@@ -553,11 +607,31 @@ const updateLineItems = (newItems: LineItem[]) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<!-- 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" />
<div class="mr-4 flex flex-col">
<span>Posten importieren</span>
<span class="text-xs text-muted-foreground">(CSV)</span>
</div>
</div>
<KbdGroup>
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>I</Kbd>
</KbdGroup>
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Preview -->
<DropdownMenuItem v-if="invoice?.paymentStatus == 'draft'"
class="flex items-center justify-between" @click="preview">
<div class="flex items-center gap-3">
<Eye :strokeWidth="1.5" class="text-current" />
<Eye :strokeWidth="1.5" class="text-muted-foreground" />
<span class="mr-4">Vorschau</span>
</div>
<KbdGroup>
@@ -595,8 +669,8 @@ const updateLineItems = (newItems: LineItem[]) => {
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator v-if="invoice && invoice.paymentStatus != 'draft'" />
<!-- Audit -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
@@ -821,8 +895,8 @@ const updateLineItems = (newItems: LineItem[]) => {
</div>
<LineItemTable :lineItems="invoice.items" @update:lineItems="updateLineItems" sticky-top="7"
:isLoading="itemsLoading" class="mt-4" />
<LineItemTable :lineItems="invoice.items" :units="units" @update:lineItems="updateLineItems"
sticky-top="7" :isLoading="itemsLoading" class="mt-4" />
</div>
@@ -5,7 +5,7 @@
import { ref, watch, HTMLAttributes, onUpdated } from 'vue'
import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils';
import { LineItem } from '@/types';
import { LineItem, Unit } from '@/types';
import { newLineItem } from '@/types/index.d'
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
@@ -20,6 +20,7 @@ import { GrowingTextarea } from '@/components/ui/growing-textarea';
const props = defineProps<{
isLoading?: boolean,
lineItems: LineItem[] | undefined,
units: Unit[],
stickyTop: number | string,
class?: HTMLAttributes['class']
}>()
@@ -29,7 +30,6 @@ const emit = defineEmits<{
}>()
const isLoading = ref(props.isLoading || false)
const units = ref(['Stück', 'Stunden', 'Tage', 'pauschal'])
const items = ref((props.lineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })) // items only uses props.lineItems as the initial value;
onUpdated(() => {
@@ -165,15 +165,15 @@ const recalculatePositions = () => {
<!-- Einh. -->
<TableCell class="w-1/8 text-center">
<Select v-model="element.unit">
<Select v-model="element.unitId">
<SelectTrigger
class="shadow-none bg-transparent p-1 h-7! dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none w-full">
<SelectValue placeholder="Einheit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="unit in units" :value="unit">
{{ unit }}
<SelectItem v-for="unit in units" :value="unit.id">
{{ unit.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
+7 -5
View File
@@ -294,7 +294,8 @@ export interface LineItem {
title: string,
description: string,
quantity: number,
unit: string,
unitId: number,
unit: Unit,
price: number,
}
@@ -310,7 +311,8 @@ export function newLineItem(isSection: boolean): LineItem {
title: '',
description: '',
quantity: 0,
unit: 'Stunden',
unitId: 2,
unit: newUnit(),
price: 93.75,
}
}
@@ -361,9 +363,9 @@ export interface Unit {
export function newUnit(): Unit {
return {
id: 0,
name: '',
symbol: null,
id: 2,
name: 'Stunden',
symbol: 'h',
createdAt: new Date(),
updatedAt: new Date()
}
+3 -3
View File
@@ -54,9 +54,9 @@
<address>
<div class="sender">
{{ $companyname }},
{{ $companyName }},
{{ $companyAddress['lineOne'] }},
@if($companyAddress['lineOne'])
@if($companyAddress['lineTwo'])
{{ $companyAddress['lineTwo'] }}
@endif
{{ $companyAddress['postalCode'] }}
@@ -170,7 +170,7 @@
{{ $item['description'] }}
</td>
<td>@toCommaFloat($item['quantity'])</td>
<td>{{ $item['unit'] }}</td>
<td>{{ $item['unit']['name'] }}</td>
<td>@toCurrency($item['price'])</td>
<td>@toCurrency($item['quantity'] * $item['price'])</td>
</tr>