[Feature] Import LineItems from from CSV and products

This commit is contained in:
2026-01-20 15:25:06 +01:00
parent 41788d6033
commit be719e66a5
23 changed files with 618 additions and 120 deletions
+13 -27
View File
@@ -57,10 +57,9 @@ public function index($invoiceId)
* ;"Hosting";"Annual hosting";1.500,50;"lump sum";1.200,00 * ;"Hosting";"Annual hosting";1.500,50;"lump sum";1.200,00
* *
* @param Request $request The HTTP request with the CSV file * @param Request $request The HTTP request with the CSV file
* @param int $invoiceId The ID of the invoice for which the items will be imported
* @return \Illuminate\Http\JsonResponse A JSON response with the imported items or an error message * @return \Illuminate\Http\JsonResponse A JSON response with the imported items or an error message
*/ */
public function importFromCsv(Request $request, $invoiceId) public function importFromCsv(Request $request)
{ {
$request->validate([ $request->validate([
'csv' => 'required|file|mimes:csv,txt' 'csv' => 'required|file|mimes:csv,txt'
@@ -133,16 +132,22 @@ public function importFromCsv(Request $request, $invoiceId)
} }
$lineItems[] = [ $lineItems[] = [
'invoice_id' => $invoiceId, 'id' => 0,
'invoice_id' => 0,
'position' => $position, 'position' => $position,
'is_section' => false, 'is_section' => false,
'title' => $itemData['title'], 'title' => $itemData['title'],
'description' => $itemData['description'] ?? '', 'description' => $itemData['description'] ?? '',
'quantity' => (float)$quantity, 'quantity' => (float)$quantity,
'unit_id' => $unit->id, 'unit_id' => $unit->id,
'unit' => [
'id' => $unit->id,
'name' => $unit->name,
'symbol' => $unit->symbol,
'created_at' => $unit->created_at,
'updated_at' => $unit->updated_at,
],
'price' => (float)$price, 'price' => (float)$price,
'created_at' => now(),
'updated_at' => now()
]; ];
} }
@@ -150,28 +155,9 @@ public function importFromCsv(Request $request, $invoiceId)
return response()->json(['message' => 'No valid items found in the CSV file'], 400); return response()->json(['message' => 'No valid items found in the CSV file'], 400);
} }
// Delete existing items for this invoice return response()->json(
LineItem::where('invoice_id', $invoiceId)->delete(); ApiDataTransformer::snakeToCamel($lineItems)
);
// Insert new items
LineItem::insert($lineItems);
// Return the newly created items
$items = LineItem::with('unit')
->where('invoice_id', $invoiceId)
->orderBy('position', 'asc')
->get();
return $items->map(function ($item) {
$itemArray = $item->toArray();
if ($item->unit) {
$itemArray['unit_name'] = $item->unit->name;
$itemArray['unit_symbol'] = $item->unit->symbol;
}
return ApiDataTransformer::snakeToCamel($itemArray);
});
} }
/** /**
@@ -2,20 +2,28 @@
<!-- TODO: Enter in LineItem = neue Zeile --> <!-- TODO: Enter in LineItem = neue Zeile -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, HTMLAttributes, onUpdated } from 'vue' import { ref, watch, HTMLAttributes, onMounted, nextTick } from 'vue'
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils'; import { cn, toCurrency } from '@/lib/utils';
import { LineItem, Unit } from '@/types'; import { LineItem, Product, Unit } from '@/types';
import { newLineItem } from '@/types/index.d' 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/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field' import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
import { Input } from '@/components/ui/crm-input'; 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 { Button } from '@/components/ui/crm-button';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
import NumberInput from '@/components/ui/number-input/NumberInput.vue'; import NumberInput from '@/components/ui/number-input/NumberInput.vue';
import { GrowingTextarea } from '@/components/ui/growing-textarea'; 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<{ const props = defineProps<{
isLoading?: boolean, isLoading?: boolean,
@@ -29,23 +37,73 @@ const emit = defineEmits<{
(e: 'update:lineItems', value: LineItem[]): void (e: 'update:lineItems', value: LineItem[]): void
}>() }>()
const isLoading = ref(props.isLoading || false) // 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 items = ref(props.lineItems ?? []) // items only uses props.lineItems as the initial value;
const updateFromParent = ref(false)
onUpdated(() => { const products = ref<Product[]>()
if (isLoading.value) isLoading.value = false 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) => { 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) emit('update:lineItems', newItems)
}, { deep: true }) }, { deep: true })
watch(() => props.lineItems, (newLineItems) => {
isLoading.value = (newLineItems?.length || 0) > 0 onMounted(async () => {
items.value = (newLineItems ?? []).slice().sort(function (a, b) { return a.position - b.position }) // Load products for insertion
}, { deep: true, once: true }) 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) => { const newItem = (isSection: boolean) => {
let item = newLineItem(isSection) let item = newLineItem(isSection)
@@ -53,6 +111,18 @@ const newItem = (isSection: boolean) => {
items.value.push(item) 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) => { const getNextPosition = (index: number, isSection: boolean) => {
let lastPosition = (index == 0) ? 0 : items.value.at(index - 1)?.position ?? 0 let lastPosition = (index == 0) ? 0 : items.value.at(index - 1)?.position ?? 0
let nextInteger = (lastPosition == Math.ceil(lastPosition)) ? lastPosition + 1 : Math.ceil(lastPosition) let nextInteger = (lastPosition == Math.ceil(lastPosition)) ? lastPosition + 1 : Math.ceil(lastPosition)
@@ -102,7 +172,7 @@ const recalculatePositions = () => {
</Table> </Table>
</div> </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"> class="table-fixed transition-opacity opacity-0 duration-300">
<!-- Dummy header so a col spanning title section in first row <!-- Dummy header so a col spanning title section in first row
@@ -129,7 +199,7 @@ const recalculatePositions = () => {
<!-- Title --> <!-- Title -->
<TableCell colspan="6" class="pt-6"> <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" /> 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" <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" /> 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"> <TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8"> <TableCell colspan="8">
<div class="flex gap-2 justify-center"> <div class="flex gap-2 justify-center">
<Button class="mt-4" variant="ghost" @click="newItem(true)"> <Button class="mt-4" variant="ghost" @click="newItem(true)">
<Plus /> Neuer Abschnitt <Plus stroke-width="1.5" /> Neuer Abschnitt
</Button> </Button>
<Button class="mt-4" variant="ghost" @click="newItem(false)"> <Button class="mt-4" variant="ghost" @click="newItem(false)">
<Plus /> Neue Zeile <Plus stroke-width="1.5" /> Neue Zeile
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -240,7 +347,8 @@ const recalculatePositions = () => {
</Table> </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!"> <Empty v-else-if="items.length < 1" class="py-8!">
<EmptyHeader> <EmptyHeader>
@@ -251,12 +359,50 @@ const recalculatePositions = () => {
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription> <EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
</EmptyHeader> </EmptyHeader>
<div class="flex gap-2"> <div class="flex gap-2">
<Button variant="action" @click="newItem(true)"> <Button variant="action" @click="newItem(true)">
<Plus stroke-width="1.5" /> Neuer Abschnitt <Plus stroke-width="1.5" /> Neuer Abschnitt
</Button> </Button>
<Button variant="action" @click="newItem(false)"> <Button variant="action" @click="newItem(false)">
<Plus stroke-width="1.5" /> Neue Zeile <Plus stroke-width="1.5" /> Neue Zeile
</Button> </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> </div>
</Empty> </Empty>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import type { ListboxRootEmits, ListboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui"
import { reactive, ref, watch } from "vue"
import { cn } from "@/lib/utils"
import { provideCommandContext } from "."
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes["class"] }>(), {
modelValue: "",
})
const emits = defineEmits<ListboxRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const allItems = ref<Map<string, string>>(new Map())
const allGroups = ref<Map<string, Set<string>>>(new Map())
const { contains } = useFilter({ sensitivity: "base" })
const filterState = reactive({
search: "",
filtered: {
/** The count of all visible items. */
count: 0,
/** Map from visible item id to its search score. */
items: new Map() as Map<string, number>,
/** Set of groups with at least one visible item. */
groups: new Set() as Set<string>,
},
})
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size
// Do nothing, each item will know to show itself because search is empty
return
}
// Reset the groups
filterState.filtered.groups = new Set()
let itemCount = 0
// Check which items should be included
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search)
filterState.filtered.items.set(id, score ? 1 : 0)
if (score)
itemCount++
}
// Check which groups have at least 1 item shown
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId)! > 0) {
filterState.filtered.groups.add(groupId)
break
}
}
}
filterState.filtered.count = itemCount
}
watch(() => filterState.search, () => {
filterItems()
})
provideCommandContext({
allItems,
allGroups,
filterState,
})
</script>
<template>
<ListboxRoot
v-bind="forwarded"
:class="cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', props.class)"
>
<slot />
</ListboxRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { useForwardPropsEmits } from "reka-ui"
import { Dialog, DialogContent } from '@/components/ui/dialog'
import Command from "./Command.vue"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-bind="forwarded">
<DialogContent class="overflow-hidden p-0 shadow-lg">
<Command class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<slot />
</Command>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { computed } from "vue"
import { cn } from "@/lib/utils"
import { useCommand } from "."
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const { filterState } = useCommand()
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0,
)
</script>
<template>
<Primitive v-if="isRender" v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
<slot />
</Primitive>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { ListboxGroupProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui"
import { computed, onMounted, onUnmounted } from "vue"
import { cn } from "@/lib/utils"
import { provideCommandGroupContext, useCommand } from "."
const props = defineProps<ListboxGroupProps & {
class?: HTMLAttributes["class"]
heading?: string
}>()
const delegatedProps = reactiveOmit(props, "class")
const { allGroups, filterState } = useCommand()
const id = useId()
const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id))
provideCommandGroupContext({ id })
onMounted(() => {
if (!allGroups.value.has(id))
allGroups.value.set(id, new Set())
})
onUnmounted(() => {
allGroups.value.delete(id)
})
</script>
<template>
<ListboxGroup
v-bind="delegatedProps"
:id="id"
:class="cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', props.class)"
:hidden="isRender ? undefined : true"
>
<ListboxGroupLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { ListboxFilterProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Search } from "lucide-vue-next"
import { ListboxFilter, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { useCommand } from "."
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ListboxFilterProps & {
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
const { filterState } = useCommand()
</script>
<template>
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
auto-focus
:class="cn('flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { ListboxItemEmits, ListboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit, useCurrentElement } from "@vueuse/core"
import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui"
import { computed, onMounted, onUnmounted, ref } from "vue"
import { cn } from "@/lib/utils"
import { useCommand, useCommandGroup } from "."
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ListboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const id = useId()
const { filterState, allItems, allGroups } = useCommand()
const groupContext = useCommandGroup()
const isRender = computed(() => {
if (!filterState.search) {
return true
}
else {
const filteredCurrentItem = filterState.filtered.items.get(id)
// If the filtered items is undefined means not in the all times map yet
// Do the first render to add into the map
if (filteredCurrentItem === undefined) {
return true
}
// Check with filter
return filteredCurrentItem > 0
}
})
const itemRef = ref()
const currentElement = useCurrentElement(itemRef)
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement))
return
// textValue to perform filter
allItems.value.set(id, currentElement.value.textContent ?? props?.value!.toString())
const groupId = groupContext?.id
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]))
}
else {
allGroups.value.get(groupId)?.add(id)
}
}
})
onUnmounted(() => {
allItems.value.delete(id)
})
</script>
<template>
<ListboxItem
v-if="isRender"
v-bind="forwarded"
:id="id"
ref="itemRef"
:class="cn('relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0', props.class)"
@select="() => {
filterState.search = ''
}"
>
<slot />
</ListboxItem>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ListboxContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxContent, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ListboxContent v-bind="forwarded" :class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)">
<div role="presentation">
<slot />
</div>
</ListboxContent>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Separator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Separator
v-bind="delegatedProps"
:class="cn('-mx-1 h-px bg-border', props.class)"
>
<slot />
</Separator>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
<slot />
</span>
</template>
@@ -0,0 +1,25 @@
import type { Ref } from "vue"
import { createContext } from "reka-ui"
export { default as Command } from "./Command.vue"
export { default as CommandDialog } from "./CommandDialog.vue"
export { default as CommandEmpty } from "./CommandEmpty.vue"
export { default as CommandGroup } from "./CommandGroup.vue"
export { default as CommandInput } from "./CommandInput.vue"
export { default as CommandItem } from "./CommandItem.vue"
export { default as CommandList } from "./CommandList.vue"
export { default as CommandSeparator } from "./CommandSeparator.vue"
export { default as CommandShortcut } from "./CommandShortcut.vue"
export const [useCommand, provideCommandContext] = createContext<{
allItems: Ref<Map<string, string>>
allGroups: Ref<Map<string, Set<string>>>
filterState: {
search: string
filtered: { count: number, items: Map<string, number>, groups: Set<string> }
}
}>("Command")
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
id?: string
}>("CommandGroup")
+4 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'reka-ui' import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>() const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>() const emits = defineEmits<DialogRootEmits>()
@@ -9,9 +10,10 @@ const forwarded = useForwardPropsEmits(props, emits)
<template> <template>
<DialogRoot <DialogRoot
v-slot="slotProps"
data-slot="dialog" data-slot="dialog"
v-bind="forwarded" v-bind="forwarded"
> >
<slot /> <slot v-bind="slotProps" />
</DialogRoot> </DialogRoot>
</template> </template>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogClose, type DialogCloseProps } from 'reka-ui' import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>() const props = defineProps<DialogCloseProps>()
</script> </script>
@@ -1,25 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/lib/utils' import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import { X } from 'lucide-vue-next' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from 'reka-ui' } from "reka-ui"
import { computed, type HTMLAttributes } from 'vue' import { cn } from "@/lib/utils"
import DialogOverlay from './DialogOverlay.vue' import DialogOverlay from "./DialogOverlay.vue"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>() defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, "class")
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@@ -29,7 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogOverlay /> <DialogOverlay />
<DialogContent <DialogContent
data-slot="dialog-content" data-slot="dialog-content"
v-bind="forwarded" v-bind="{ ...$attrs, ...forwarded }"
:class=" :class="
cn( cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
@@ -39,6 +41,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<slot /> <slot />
<DialogClose <DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<X /> <X />
@@ -1,15 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/lib/utils' import type { DialogDescriptionProps } from "reka-ui"
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui' import type { HTMLAttributes } from "vue"
import { computed, type HTMLAttributes } from 'vue' import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, "class")
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes['class'] }>() const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script> </script>
<template> <template>
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
@@ -1,15 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/lib/utils' import type { DialogOverlayProps } from "reka-ui"
import { DialogOverlay, type DialogOverlayProps } from 'reka-ui' import type { HTMLAttributes } from "vue"
import { computed, type HTMLAttributes } from 'vue' import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, "class")
const { class: _, ...delegated } = props
return delegated
})
</script> </script>
<template> <template>
@@ -1,25 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/lib/utils' import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import { X } from 'lucide-vue-next' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from 'reka-ui' } from "reka-ui"
import { computed, type HTMLAttributes } from 'vue' import { cn } from "@/lib/utils"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>() defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, "class")
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@@ -36,7 +36,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
props.class, props.class,
) )
" "
v-bind="forwarded" v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => { @pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent; const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement; const target = originalEvent.target as HTMLElement;
@@ -1,15 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/lib/utils' import type { DialogTitleProps } from "reka-ui"
import { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui' import type { HTMLAttributes } from "vue"
import { computed, type HTMLAttributes } from 'vue' import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, "class")
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogTrigger, type DialogTriggerProps } from 'reka-ui' import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>() const props = defineProps<DialogTriggerProps>()
</script> </script>
+10 -10
View File
@@ -1,10 +1,10 @@
export { default as Dialog } from './Dialog.vue' export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from './DialogClose.vue' export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from './DialogContent.vue' export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from './DialogDescription.vue' export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from './DialogFooter.vue' export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from './DialogHeader.vue' export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from './DialogOverlay.vue' export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from './DialogScrollContent.vue' export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from './DialogTitle.vue' export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from './DialogTrigger.vue' export { default as DialogTrigger } from "./DialogTrigger.vue"