Files
Caramel-CRM/resources/js/components/documents/LineItemTable.vue
T

427 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- TODO: Mengenfeld Komma als decimal point -->
<!-- TODO: Enter in LineItem = neue Zeile -->
<script setup lang="ts">
import { ref, watch, HTMLAttributes, onMounted, nextTick } from 'vue'
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 { 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 { 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";
const DEBUG = ref(true)
const props = defineProps<{
isLoading?: boolean,
lineItems: LineItem[] | undefined,
units: Unit[],
stickyTop: number | string,
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
(e: 'update:lineItems', value: LineItem[]): void
}>()
// 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 })
/**
* Watch for changes in items and emit to parent
*/
watch(items, (newItems) => {
if (DEBUG.value) {
console.group('LineItemTable watch items')
console.log(`isLoading: ${props.isLoading},\tupdateFromParent: ${updateFromParent.value}`)
console.groupEnd()
}
// Don't emit changes in loading
if (props.isLoading || updateFromParent.value) return
console.log('emit update:lineItems')
emit('update:lineItems', newItems)
}, { deep: true })
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 })
}
})
const newItem = (isSection: boolean) => {
let item = newLineItem(isSection)
item.position = getNextPosition(items.value.length, isSection)
items.value.push(item)
}
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)
}
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
}
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 = getNextPosition(i, items.value[i].isSection)
}
}
</script>
<template>
<div :class="cn(props.class)">
<div class="backdrop-blur-sm bg-background/90 sticky z-1 pt-8"
:style="'top: calc(var(--spacing) * ' + (props.stickyTop ? props.stickyTop : 0) + ');'">
<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-8"></TableHead>
</TableRow>
</TableHeader>
</Table>
</div>
<Table :class="{ 'opacity-100!': !props.isLoading && items.length >= 1 }"
class="table-fixed transition-opacity opacity-0 duration-300">
<!-- Dummy header so a col spanning title section in first row
doesnt 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>
<draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost"
@end="recalculatePositions">
<template #item="{ element }">
<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" />
</TableCell>
<!-- Title -->
<TableCell colspan="6" class="pt-6">
<Input v-model="element.title" placeholder="Titel" id=""
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">
<Trash2 :size="18" stroke-width="1.5" />
</Button>
</TableCell>
</TableRow>
<TableRow v-else>
<TableCell class="handle px-1 cursor-move w-6">
<GripVertical :size="18" stroke-width="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 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="Beschreibung"
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" />
</TableCell>
<!-- Einh. -->
<TableCell class="w-1/8 text-center">
<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.id">
{{ unit.name }}
</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 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" />
</NumberFieldContent>
</NumberField>
</TableCell>
<!-- Preis -->
<TableCell class="w-1/8 text-right tabular-nums">
<NumberInput v-model="element.price"
class="bg-transparent p-1 h-7! dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 rounded shadow-none!" />
</TableCell>
<!-- 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" />
</Button>
<!-- <Button variant="ghost" size="sm" @click=""
class="has-[>svg]:px-1 text-muted-foreground">
<BetweenHorizonalEnd stroke-width="1.5"/>
</Button> -->
</TableCell>
</TableRow>
</template>
</draggable>
<TableFooter v-if="items.length >= 1" class="bg-transparent">
<TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8">
<div class="flex gap-2 justify-center">
<Button class="mt-4" variant="ghost" @click="newItem(true)">
<Plus stroke-width="1.5" /> Neuer Abschnitt
</Button>
<Button class="mt-4" variant="ghost" @click="newItem(false)">
<Plus stroke-width="1.5" /> 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" />
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-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>
{{ product.title }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</TableCell>
</TableRow>
</TableFooter>
</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" />
</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>
<Button variant="action" @click="newItem(false)">
<Plus stroke-width="1.5" /> Neue Zeile
</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">
<!-- 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>
{{ product.title }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</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>