[Feature] Import LineItems from from CSV and products
This commit is contained in:
@@ -2,20 +2,28 @@
|
||||
<!-- TODO: Enter in LineItem = neue Zeile -->
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref, watch, HTMLAttributes, onUpdated } from 'vue'
|
||||
import { ref, watch, HTMLAttributes, onMounted, nextTick } from 'vue'
|
||||
import draggable from 'vuedraggable';
|
||||
import { cn, toCurrency } from '@/lib/utils';
|
||||
import { LineItem, Unit } from '@/types';
|
||||
import { newLineItem } from '@/types/index.d'
|
||||
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 } from 'lucide-vue-next';
|
||||
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,
|
||||
@@ -29,23 +37,73 @@ const emit = defineEmits<{
|
||||
(e: 'update:lineItems', value: LineItem[]): void
|
||||
}>()
|
||||
|
||||
const isLoading = ref(props.isLoading || false)
|
||||
const items = ref((props.lineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })) // items only uses props.lineItems as the initial value;
|
||||
// const isLoading = ref(props.isLoading || false)
|
||||
const items = ref(props.lineItems ?? []) // items only uses props.lineItems as the initial value;
|
||||
const updateFromParent = ref(false)
|
||||
|
||||
onUpdated(() => {
|
||||
if (isLoading.value) isLoading.value = 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 (isLoading.value) return
|
||||
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 })
|
||||
|
||||
watch(() => props.lineItems, (newLineItems) => {
|
||||
isLoading.value = (newLineItems?.length || 0) > 0
|
||||
items.value = (newLineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })
|
||||
}, { deep: true, once: 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)
|
||||
@@ -53,6 +111,18 @@ const newItem = (isSection: boolean) => {
|
||||
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)
|
||||
@@ -102,7 +172,7 @@ const recalculatePositions = () => {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Table :class="{ 'opacity-100!': !isLoading && items.length >= 1 }"
|
||||
<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
|
||||
@@ -129,7 +199,7 @@ const recalculatePositions = () => {
|
||||
|
||||
<!-- Title -->
|
||||
<TableCell colspan="6" class="pt-6">
|
||||
<Input v-model="element.title" placeholder="Titel"
|
||||
<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" />
|
||||
@@ -227,12 +297,49 @@ const recalculatePositions = () => {
|
||||
<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 /> Neuer Abschnitt
|
||||
<Plus stroke-width="1.5" /> Neuer Abschnitt
|
||||
</Button>
|
||||
|
||||
<Button class="mt-4" variant="ghost" @click="newItem(false)">
|
||||
<Plus /> Neue Zeile
|
||||
<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>
|
||||
@@ -240,7 +347,8 @@ const recalculatePositions = () => {
|
||||
|
||||
</Table>
|
||||
|
||||
<Loader2 v-if="isLoading" class="mx-auto mt-8 h-6 w-6 animate-spin text-muted-foreground" stroke-width="1.5" />
|
||||
<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>
|
||||
@@ -251,12 +359,50 @@ const recalculatePositions = () => {
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user