2025-10-20 08:57:51 +02:00
|
|
|
|
<!-- TODO: Mengenfeld Komma als decimal point -->
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<!-- TODO: Enter in LineItem = neue Zeile -->
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
|
import { ref, watch, HTMLAttributes, onMounted, nextTick } from 'vue'
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import draggable from 'vuedraggable';
|
|
|
|
|
|
import { cn, toCurrency } from '@/lib/utils';
|
2026-01-20 15:25:06 +01:00
|
|
|
|
import { LineItem, Product, Unit } from '@/types';
|
|
|
|
|
|
import { newLineItem, newUnit } from '@/types/index.d'
|
2026-02-17 10:35:03 +01:00
|
|
|
|
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/crm-table';
|
2025-11-18 20:46:40 +01:00
|
|
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
|
2025-11-21 13:21:59 +01:00
|
|
|
|
import { Input } from '@/components/ui/crm-input';
|
2026-01-20 15:25:06 +01:00
|
|
|
|
import { Loader2, GripVertical, Trash2, Plus, TextSelect, CheckIcon, ChevronsUpDownIcon } from 'lucide-vue-next';
|
2025-11-21 13:21:59 +01:00
|
|
|
|
import { Button } from '@/components/ui/crm-button';
|
2025-11-18 20:46:40 +01:00
|
|
|
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
|
2026-02-17 10:35:03 +01:00
|
|
|
|
import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue';
|
2025-11-21 13:21:59 +01:00
|
|
|
|
import { GrowingTextarea } from '@/components/ui/growing-textarea';
|
2026-01-20 15:25:06 +01:00
|
|
|
|
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";
|
2026-02-17 10:35:03 +01:00
|
|
|
|
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
|
2026-01-20 15:25:06 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const DEBUG = ref(true)
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
2025-11-18 20:46:40 +01:00
|
|
|
|
isLoading?: boolean,
|
2025-11-18 10:27:49 +01:00
|
|
|
|
lineItems: LineItem[] | undefined,
|
2025-12-08 13:20:52 +01:00
|
|
|
|
units: Unit[],
|
2025-11-14 11:55:41 +01:00
|
|
|
|
stickyTop: number | string,
|
2025-10-20 08:57:51 +02:00
|
|
|
|
class?: HTMLAttributes['class']
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
(e: 'update:lineItems', value: LineItem[]): void
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
|
// const isLoading = ref(props.isLoading || false)
|
|
|
|
|
|
const items = ref(props.lineItems ?? []) // items only uses props.lineItems as the initial value;
|
|
|
|
|
|
const updateFromParent = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
const products = ref<Product[]>()
|
|
|
|
|
|
const productsComboboxOpen = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Watch for data from parent view
|
|
|
|
|
|
*/
|
|
|
|
|
|
watch(() => props.lineItems, async (newLineItems) => {
|
|
|
|
|
|
// isLoading.value = (newLineItems?.length || 0) > 0
|
|
|
|
|
|
|
|
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.group('LineItemTable watch props.lineItems')
|
|
|
|
|
|
console.log(`isLoading: ${props.isLoading},\tupdateFromParent: ${updateFromParent.value}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set flag to indicate this is an external update
|
|
|
|
|
|
updateFromParent.value = true
|
|
|
|
|
|
|
|
|
|
|
|
// Only update if the items actually changed
|
|
|
|
|
|
if (JSON.stringify(items.value) !== JSON.stringify(newLineItems)) {
|
|
|
|
|
|
items.value = (newLineItems ?? [])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('already up to date, no change')
|
|
|
|
|
|
}
|
|
|
|
|
|
// items.value = (newLineItems ?? [])
|
|
|
|
|
|
|
|
|
|
|
|
// Reset flag after next tick
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
updateFromParent.value = false
|
|
|
|
|
|
|
|
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.log(`isLoading: ${props.isLoading},\tupdateFromParent: ${updateFromParent.value}`)
|
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { deep: true })
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Watch for changes in items and emit to parent
|
|
|
|
|
|
*/
|
2025-10-20 08:57:51 +02:00
|
|
|
|
watch(items, (newItems) => {
|
2026-01-20 15:25:06 +01:00
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.group('LineItemTable watch items')
|
|
|
|
|
|
console.log(`isLoading: ${props.isLoading},\tupdateFromParent: ${updateFromParent.value}`)
|
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
|
}
|
2025-11-18 20:46:40 +01:00
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
|
// Don't emit changes in loading
|
|
|
|
|
|
if (props.isLoading || updateFromParent.value) return
|
|
|
|
|
|
console.log('emit update:lineItems')
|
2025-10-20 08:57:51 +02:00
|
|
|
|
emit('update:lineItems', newItems)
|
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
// Load products for insertion
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await axios.get('/api/products')
|
|
|
|
|
|
products.value = response.data
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error('Fehler beim Laden der Produkte', { description: (error as AxiosError).message })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2025-11-19 10:12:45 +01:00
|
|
|
|
const newItem = (isSection: boolean) => {
|
|
|
|
|
|
let item = newLineItem(isSection)
|
|
|
|
|
|
item.position = getNextPosition(items.value.length, isSection)
|
2025-10-20 08:57:51 +02:00
|
|
|
|
items.value.push(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
|
const insertProduct = (product: Product) => {
|
|
|
|
|
|
let item = newLineItem(false)
|
|
|
|
|
|
item.title = product.title
|
|
|
|
|
|
item.description = product.description ?? ''
|
|
|
|
|
|
item.unit = product.unit ?? newUnit()
|
|
|
|
|
|
item.quantity = 1
|
|
|
|
|
|
item.price = product.price ?? 0
|
|
|
|
|
|
|
|
|
|
|
|
item.position = getNextPosition(items.value.length, false)
|
|
|
|
|
|
items.value.push(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-19 10:12:45 +01:00
|
|
|
|
const getNextPosition = (index: number, isSection: boolean) => {
|
|
|
|
|
|
let lastPosition = (index == 0) ? 0 : items.value.at(index - 1)?.position ?? 0
|
|
|
|
|
|
let nextInteger = (lastPosition == Math.ceil(lastPosition)) ? lastPosition + 1 : Math.ceil(lastPosition)
|
|
|
|
|
|
|
|
|
|
|
|
// item: next integer
|
|
|
|
|
|
// sections: middle between last element and next integer
|
|
|
|
|
|
return isSection ?
|
|
|
|
|
|
lastPosition + (nextInteger - lastPosition) / 2
|
|
|
|
|
|
: nextInteger
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
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++) {
|
2025-11-19 10:12:45 +01:00
|
|
|
|
items.value[i].position = getNextPosition(i, items.value[i].isSection)
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
|
|
|
|
|
|
<div :class="cn(props.class)">
|
|
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<div class="backdrop-blur-sm bg-background/90 sticky z-1 pt-8"
|
|
|
|
|
|
:style="'top: calc(var(--spacing) * ' + (props.stickyTop ? props.stickyTop : 0) + ');'">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<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>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<TableHead class="w-8"></TableHead>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
|
<Table :class="{ 'opacity-100!': !props.isLoading && items.length >= 1 }"
|
2025-11-18 20:46:40 +01:00
|
|
|
|
class="table-fixed transition-opacity opacity-0 duration-300">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2025-11-19 12:47:22 +01:00
|
|
|
|
<!-- Dummy header so a col spanning title section in first row
|
|
|
|
|
|
doesn’t mess up col widths -->
|
|
|
|
|
|
<TableRow class="border-0">
|
|
|
|
|
|
<TableHead class="h-0 w-6 px-0"></TableHead>
|
|
|
|
|
|
<TableHead class="h-0 w-8 px-0 text-center"></TableHead>
|
|
|
|
|
|
<TableHead class="h-0 "></TableHead>
|
|
|
|
|
|
<TableHead class="h-0 w-1/8"></TableHead>
|
|
|
|
|
|
<TableHead class="h-0 w-20 text-center"></TableHead>
|
|
|
|
|
|
<TableHead class="h-0 w-1/8 text-right pr-5"></TableHead>
|
|
|
|
|
|
<TableHead class="h-0 w-1/8 text-right"></TableHead>
|
|
|
|
|
|
<TableHead class="h-0 w-8"></TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost"
|
|
|
|
|
|
@end="recalculatePositions">
|
|
|
|
|
|
<template #item="{ element }">
|
|
|
|
|
|
|
2025-11-19 10:12:45 +01:00
|
|
|
|
<TableRow v-if="element.isSection">
|
|
|
|
|
|
<TableCell class="handle px-1 cursor-move w-6">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<GripVertical :size="18" class="text-muted-foreground" />
|
2025-11-19 10:12:45 +01:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
2025-11-19 11:42:54 +01:00
|
|
|
|
<!-- Title -->
|
2025-11-19 10:12:45 +01:00
|
|
|
|
<TableCell colspan="6" class="pt-6">
|
2026-01-20 15:25:06 +01:00
|
|
|
|
<Input v-model="element.title" placeholder="Titel" id=""
|
2025-11-19 10:12:45 +01:00
|
|
|
|
class="font-bold text-base! p-1 h-7! m-0 -mb-1 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" />
|
|
|
|
|
|
<GrowingTextarea v-model="element.description" placeholder="Text"
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 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">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Trash2 :size="18" />
|
2025-11-19 10:12:45 +01:00
|
|
|
|
</Button>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
|
2025-11-19 11:42:54 +01:00
|
|
|
|
|
2025-11-19 10:12:45 +01:00
|
|
|
|
<TableRow v-else>
|
|
|
|
|
|
<TableCell class="handle px-1 cursor-move w-6">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<GripVertical :size="18" class="text-muted-foreground" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Pos. -->
|
|
|
|
|
|
<TableCell class="text-center position w-8">{{ element.position }}</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Posten -->
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<Input v-model="element.title" placeholder="Posten"
|
2025-11-14 11:55:41 +01:00
|
|
|
|
class="font-bold p-1 h-7! m-0 -mb-1 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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<GrowingTextarea v-model="element.description" placeholder="Beschreibung"
|
2025-11-14 11:55:41 +01:00
|
|
|
|
class="text-muted-foreground font-light m-0 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 py-0 px-1 m-0 border-none shadow-none" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Einh. -->
|
|
|
|
|
|
<TableCell class="w-1/8 text-center">
|
2025-12-08 13:20:52 +01:00
|
|
|
|
<Select v-model="element.unitId">
|
2025-10-22 16:11:52 +02:00
|
|
|
|
<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">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<SelectValue placeholder="Einheit" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectGroup>
|
2025-12-08 13:20:52 +01:00
|
|
|
|
<SelectItem v-for="unit in units" :value="unit.id">
|
|
|
|
|
|
{{ unit.name }}
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectGroup>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Anz. -->
|
|
|
|
|
|
<TableCell class="w-20 text-center">
|
|
|
|
|
|
<NumberField v-model="element.quantity" :step="0.5" :format-options="{}">
|
|
|
|
|
|
<NumberFieldContent>
|
2025-10-22 16:11:52 +02:00
|
|
|
|
<NumberFieldDecrement
|
|
|
|
|
|
class="text-muted-foreground p-1 h-7! bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66" />
|
|
|
|
|
|
<NumberFieldInput
|
|
|
|
|
|
class="h-6 border-none p-1 h-7! bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66" />
|
|
|
|
|
|
<NumberFieldIncrement
|
|
|
|
|
|
class="text-muted-foreground p-1 h-7! bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</NumberFieldContent>
|
|
|
|
|
|
</NumberField>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Preis -->
|
|
|
|
|
|
<TableCell class="w-1/8 text-right tabular-nums">
|
2025-11-04 13:51:31 +01:00
|
|
|
|
<NumberInput v-model="element.price"
|
2025-10-22 16:11:52 +02:00
|
|
|
|
class="bg-transparent p-1 h-7! dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 rounded shadow-none!" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Total -->
|
|
|
|
|
|
<TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity)
|
2026-02-17 10:35:03 +01:00
|
|
|
|
}}
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Buttons -->
|
2025-11-19 10:12:45 +01:00
|
|
|
|
<TableCell class="w-8 text-right px-1">
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<Button variant="ghost" size="sm" @click="deleteItem(element)"
|
2025-10-20 08:57:51 +02:00
|
|
|
|
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Trash2 />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</Button>
|
2025-11-19 12:47:22 +01:00
|
|
|
|
|
|
|
|
|
|
<!-- <Button variant="ghost" size="sm" @click=""
|
|
|
|
|
|
class="has-[>svg]:px-1 text-muted-foreground">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<BetweenHorizonalEnd />
|
2025-11-19 12:47:22 +01:00
|
|
|
|
</Button> -->
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</draggable>
|
|
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
<TableFooter v-if="items.length >= 1" class="bg-transparent">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<TableRow class="hover:bg-transparent dark:hover:bg-transparent">
|
2025-11-19 10:12:45 +01:00
|
|
|
|
<TableCell colspan="8">
|
|
|
|
|
|
<div class="flex gap-2 justify-center">
|
2026-01-20 15:25:06 +01:00
|
|
|
|
|
2025-11-19 10:12:45 +01:00
|
|
|
|
<Button class="mt-4" variant="ghost" @click="newItem(true)">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Plus /> Neuer Abschnitt
|
2025-11-19 10:12:45 +01:00
|
|
|
|
</Button>
|
2026-01-20 15:25:06 +01:00
|
|
|
|
|
2025-11-19 10:12:45 +01:00
|
|
|
|
<Button class="mt-4" variant="ghost" @click="newItem(false)">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Plus /> Neue Zeile
|
2025-11-19 10:12:45 +01:00
|
|
|
|
</Button>
|
2026-01-20 15:25:06 +01:00
|
|
|
|
|
|
|
|
|
|
<Popover v-model:open="productsComboboxOpen">
|
|
|
|
|
|
<PopoverTrigger as-child>
|
|
|
|
|
|
<Button variant="ghost" role="combobox" :aria-expanded="productsComboboxOpen" class="mt-4"
|
|
|
|
|
|
:disabled="!products || products.length === 0">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Plus />
|
2026-01-20 15:25:06 +01:00
|
|
|
|
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">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Thumbnail -->
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-sm shrink-0">
|
2026-01-20 15:25:06 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-11-19 10:12:45 +01:00
|
|
|
|
</div>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableFooter>
|
|
|
|
|
|
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
|
<Loader2 v-if="props.isLoading" class="mx-auto mt-8 h-6 w-6 animate-spin text-muted-foreground"
|
2026-02-17 10:35:03 +01:00
|
|
|
|
/>
|
2025-11-18 20:46:40 +01:00
|
|
|
|
|
|
|
|
|
|
<Empty v-else-if="items.length < 1" class="py-8!">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<EmptyHeader>
|
|
|
|
|
|
<EmptyMedia variant="icon">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<TextSelect class="text-muted-foreground" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</EmptyMedia>
|
|
|
|
|
|
<EmptyTitle>Diese Rechnung ist leer</EmptyTitle>
|
|
|
|
|
|
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
|
|
|
|
|
|
</EmptyHeader>
|
2025-11-19 10:12:45 +01:00
|
|
|
|
<div class="flex gap-2">
|
2026-01-20 15:25:06 +01:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<ButtonGroup>
|
|
|
|
|
|
|
|
|
|
|
|
<Button @click="newItem(true)">
|
|
|
|
|
|
<Plus /> Neuer Abschnitt
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<Button @click="newItem(false)">
|
|
|
|
|
|
<Plus /> Neue Zeile
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<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">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 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>
|
2026-01-20 15:25:06 +01:00
|
|
|
|
|
|
|
|
|
|
|
2025-11-19 10:12:45 +01:00
|
|
|
|
</div>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</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>
|