Add initial Code

This commit is contained in:
2025-10-20 08:57:51 +02:00
parent d204098d8e
commit 9da301c4f1
447 changed files with 34393 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { SidebarInset } from '@/components/ui/sidebar';
import { computed } from 'vue';
interface Props {
variant?: 'header' | 'sidebar';
class?: string;
}
const props = defineProps<Props>();
const className = computed(() => props.class);
</script>
<template>
<SidebarInset v-if="props.variant === 'sidebar'" :class="className">
<slot />
</SidebarInset>
<main v-else class="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl" :class="className">
<slot />
</main>
</template>
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import AppLogo from '@/components/AppLogo.vue';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { getInitials } from '@/composables/useInitials';
import { toUrl, urlIsActive } from '@/lib/utils';
import { dashboard, crm, invoices, customers } from '@/routes';
import type { BreadcrumbItem, NavItem } from '@/types';
import { InertiaLinkProps, Link, usePage } from '@inertiajs/vue3';
import { BookOpen, Folder, LayoutGrid, Kanban, Euro, Contact, Search } from 'lucide-vue-next';
import { computed } from 'vue';
interface Props {
breadcrumbs?: BreadcrumbItem[];
}
const props = withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
const page = usePage();
const auth = computed(() => page.props.auth);
const isCurrentRoute = computed(() => (url: NonNullable<InertiaLinkProps['href']>) => urlIsActive(url, page.url));
const activeItemStyles = computed(
() => (url: NonNullable<InertiaLinkProps['href']>) =>
isCurrentRoute.value(toUrl(url)) ? 'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100' : '',
);
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'CRM',
href: crm(),
icon: Kanban,
},
{
title: 'Rechnungsstellung',
href: invoices(),
icon: Euro,
},
{
title: 'Kunden',
href: customers(),
icon: Contact,
},
];
const rightNavItems: NavItem[] = [
// {
// title: 'Repository',
// href: 'https://github.com/laravel/vue-starter-kit',
// icon: Folder,
// },
// {
// title: 'Documentation',
// href: 'https://laravel.com/docs/starter-kits#vue',
// icon: BookOpen,
// },
];
</script>
<template>
<div>
<div class="border-b border-sidebar-border/80">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div class="lg:hidden">
<Sheet>
<SheetTrigger :as-child="true">
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
<Menu class="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" class="w-[300px] p-6">
<SheetTitle class="sr-only">Navigation Menu</SheetTitle>
<SheetHeader class="flex justify-start text-left">
<AppLogoIcon class="size-6 fill-current text-black dark:text-white" />
</SheetHeader>
<div class="flex h-full flex-1 flex-col justify-between space-y-4 py-6">
<nav class="-mx-3 space-y-1">
<Link
v-for="item in mainNavItems"
:key="item.title"
:href="item.href"
class="flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent"
:class="activeItemStyles(item.href)"
>
<component v-if="item.icon" :is="item.icon" class="h-5 w-5" />
{{ item.title }}
</Link>
</nav>
<div class="flex flex-col space-y-4">
<a
v-for="item in rightNavItems"
:key="item.title"
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
class="flex items-center space-x-2 text-sm font-medium"
>
<component v-if="item.icon" :is="item.icon" class="h-5 w-5" />
<span>{{ item.title }}</span>
</a>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<Link :href="dashboard()" class="flex items-center gap-x-2">
<AppLogo />
</Link>
<!-- Desktop Menu -->
<div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch">
<NavigationMenuList class="flex h-full items-stretch space-x-2">
<NavigationMenuItem v-for="(item, index) in mainNavItems" :key="index" class="relative flex h-full items-center">
<Link
:class="[navigationMenuTriggerStyle(), activeItemStyles(item.href), 'h-9 cursor-pointer px-3']"
:href="item.href"
>
<component v-if="item.icon" :is="item.icon" class="mr-2 h-4 w-4" />
{{ item.title }}
</Link>
<div
v-if="isCurrentRoute(item.href)"
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
></div>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div class="ml-auto flex items-center space-x-2">
<div class="relative flex items-center space-x-1">
<Button variant="ghost" size="icon" class="group h-9 w-9 cursor-pointer">
<Search class="size-5 opacity-80 group-hover:opacity-100" />
</Button>
<div class="hidden space-x-1 lg:flex">
<template v-for="item in rightNavItems" :key="item.title">
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="icon" as-child class="group h-9 w-9 cursor-pointer">
<a :href="toUrl(item.href)" target="_blank" rel="noopener noreferrer">
<span class="sr-only">{{ item.title }}</span>
<component :is="item.icon" class="size-5 opacity-80 group-hover:opacity-100" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ item.title }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="relative size-10 w-auto rounded-full p-1 focus-within:ring-2 focus-within:ring-primary"
>
<Avatar class="size-8 overflow-hidden rounded-full">
<AvatarImage v-if="auth.user.avatar" :src="auth.user.avatar" :alt="auth.user.name" />
<AvatarFallback class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white">
{{ getInitials(auth.user?.name) }}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<UserMenuContent :user="auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div v-if="props.breadcrumbs.length > 1" class="flex w-full border-b border-sidebar-border/70">
<div class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</div>
</div>
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import AppLogoIcon from '@/components/AppLogoIcon.vue';
</script>
<template>
<div class="flex aspect-square size-8 items-center justify-center">
<AppLogoIcon />
</div>
<span class="font-bold text-zinc-600 dark:text-zinc-400 text-sm whitespace-nowrap overflow-hidden group-data-[collapsible=icon]:opacity-0 transition-[opacity]">Caramal CRM</span>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
defineOptions({
inheritAttrs: false,
});
interface Props {
className?: HTMLAttributes['class'];
}
defineProps<Props>();
</script>
<template>
<!-- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" :class="className" v-bind="$attrs">
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z" />
</svg> -->
<img :src="'/storage/images/Logo-3.webp'" alt="Caramel CRM Logo" class="w-24" />
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { SidebarProvider } from '@/components/ui/sidebar';
import { usePage } from '@inertiajs/vue3';
interface Props {
variant?: 'header' | 'sidebar';
}
defineProps<Props>();
const isOpen = usePage().props.sidebarOpen;
</script>
<template>
<div v-if="variant === 'header'" class="flex min-h-screen w-full flex-col">
<slot />
</div>
<SidebarProvider v-else :default-open="isOpen">
<slot />
</SidebarProvider>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
import { dashboard, crm, offers, invoices, timesheets, customers, leads, achievements } from '@/routes';
import { edit } from '@/routes/profile';
import { type NavItem, type NavGroup } from '@/types';
import { Link } from '@inertiajs/vue3';
import { Kanban, Euro, Contact, Trophy, Calculator, Settings, Target, BookUser, Timer, Headset, IdCard } from 'lucide-vue-next';
import AppLogo from './AppLogo.vue';
const mainNavGroups: NavGroup[] = [
{
title: 'CRM',
items: [
{
title: 'Pipeline',
href: crm(),
icon: Kanban,
color: 'text-pink-500',
},
{
title: 'Akquise',
href: leads(),
icon: Headset,
color: 'text-blue-500',
},
{
title: 'Kunden',
href: customers(),
icon: BookUser,
color: 'text-lime-500',
},
{
title: 'Erfolge',
href: achievements(),
icon: Trophy,
color: 'text-yellow-500',
},
],
},
{
title: 'Finanzvorgänge',
items: [
{
title: 'Angebote',
href: offers(),
icon: Calculator,
color: 'text-cyan-500',
},
{
title: 'Rechnungen',
href: invoices(),
icon: Euro,
color: 'text-pink-700',
},
{
title: 'Zeiterfassung',
href: timesheets(),
icon: Timer,
color: 'text-green-500',
},
],
}
];
const footerNavItems: NavItem[] = [
{
title: 'Einstellungen',
href: edit(),
icon: Settings,
color: 'text-gray-400',
}
];
</script>
<template>
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
<Link :href="dashboard()" class="flex row items-center gap-1 h-12 mb-4">
<AppLogo />
</Link>
</SidebarHeader>
<SidebarContent>
<NavMain :groups="mainNavGroups" />
</SidebarContent>
<SidebarFooter>
<NavFooter :items="footerNavItems" />
</SidebarFooter>
</Sidebar>
<slot />
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItemType } from '@/types';
import NavUser from '@/components/NavUser.vue';
withDefaults(
defineProps<{
breadcrumbs?: BreadcrumbItemType[];
}>(),
{
breadcrumbs: () => [],
},
);
</script>
<template>
<header
class="flex justify-between h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear md:px-4 z-10">
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1 text-primary-foreground" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
</div>
<NavUser />
</header>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useAppearance } from '@/composables/useAppearance';
import { Monitor, Moon, Sun } from 'lucide-vue-next';
const { appearance, updateAppearance } = useAppearance();
const tabs = [
{ value: 'light', Icon: Sun, label: 'Hell' },
{ value: 'dark', Icon: Moon, label: 'Dunkel' },
{ value: 'system', Icon: Monitor, label: 'System' },
] as const;
</script>
<template>
<div class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800">
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
@click="updateAppearance(value)"
:class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
]"
>
<component :is="Icon" class="-ml-1 h-4 w-4" />
<span class="ml-1.5 text-sm">{{ label }}</span>
</button>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb';
import { Link } from '@inertiajs/vue3';
interface BreadcrumbItemType {
title: string;
href?: string;
}
defineProps<{
breadcrumbs: BreadcrumbItemType[];
}>();
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem>
<template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
</template>
<template v-else>
<BreadcrumbLink as-child>
<Link :href="item.href ?? '#'">{{ item.title }}</Link>
</BreadcrumbLink>
</template>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
</template>
</BreadcrumbList>
</Breadcrumb>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { Form } from '@inertiajs/vue3';
import { ref } from 'vue';
// Components
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const passwordInput = ref<InstanceType<typeof Input> | null>(null);
</script>
<template>
<div class="space-y-6">
<HeadingSmall title="Delete account" description="Delete your account and all of its resources" />
<div class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p>
<p class="text-sm">Please proceed with caution, this cannot be undone.</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button">Delete account</Button>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.$el?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
<DialogDescription>
Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your
password to confirm you would like to permanently delete your account.
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<Label for="password" class="sr-only">Password</Label>
<Input id="password" type="password" name="password" ref="passwordInput" placeholder="Password" />
<InputError :message="errors.password" />
</div>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
>
Cancel
</Button>
</DialogClose>
<Button type="submit" variant="destructive" :disabled="processing" data-test="confirm-delete-user-button"> Delete account </Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
interface Props {
title: string;
description?: string;
}
defineProps<Props>();
</script>
<template>
<div class="mb-8 space-y-0.5">
<h2 class="text-xl font-semibold tracking-tight">{{ title }}</h2>
<p v-if="description" class="text-sm text-muted-foreground">
{{ description }}
</p>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
interface Props {
title: string;
description?: string;
}
defineProps<Props>();
</script>
<template>
<header>
<h3 class="mb-0.5 text-base font-medium">{{ title }}</h3>
<p v-if="description" class="text-sm text-muted-foreground">
{{ description }}
</p>
</header>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import * as icons from 'lucide-vue-next';
import { computed } from 'vue';
interface Props {
name: string;
class?: string;
size?: number | string;
color?: string;
strokeWidth?: number | string;
}
const props = withDefaults(defineProps<Props>(), {
class: '',
size: 16,
strokeWidth: 2,
});
const className = computed(() => cn('h-4 w-4', props.class));
const icon = computed(() => {
const iconName = props.name.charAt(0).toUpperCase() + props.name.slice(1);
return (icons as Record<string, any>)[iconName];
});
</script>
<template>
<component :is="icon" :class="className" :size="size" :stroke-width="strokeWidth" :color="color" />
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{
message?: string;
}>();
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-500">
{{ message }}
</p>
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
import { toUrl } from '@/lib/utils';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/vue3';
interface Props {
items: NavItem[];
class?: string;
}
defineProps<Props>();
</script>
<template>
<SidebarGroup :class="`group-data-[collapsible=icon]:p-0 ${$props.class || ''}`">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
class="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
as-child>
<!-- <a :href="toUrl(item.href)" target="_blank" rel="noopener noreferrer">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</a> -->
<Link :href="item.href">
<component :is="item.icon" :class="item.color" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
import { urlIsActive } from '@/lib/utils';
import { type NavGroup } from '@/types';
import { Link, usePage } from '@inertiajs/vue3';
import { LayoutGrid } from 'lucide-vue-next';
import { dashboard } from '@/routes';
import SidebarSeparator from './ui/sidebar/SidebarSeparator.vue';
defineProps<{
groups: NavGroup[];
}>();
const page = usePage();
</script>
<template>
<SidebarGroup class="px-2 py-0">
<SidebarMenuItem :key="'dashboard'">
<SidebarMenuButton as-child :is-active="urlIsActive(dashboard(), page.url)" :tooltip="'Dashboard'">
<Link :href="dashboard()">
<component :is="LayoutGrid" class="text-gray-400" />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarGroup>
<SidebarGroup class="px-2 py-0" v-for="group in groups">
<SidebarSeparator class="absolute mt-6 shrink transition-[opacity] opacity-0 group-data-[collapsible=icon]:opacity-100" style="width: calc(var(--spacing) * 4);" />
<SidebarGroupLabel class="text-bold uppercase text-gray-400 mt-2 group-data-[collapsible=icon]:mt-2">
{{ group.title }}
</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in group.items" :key="item.title">
<SidebarMenuButton as-child :is-active="urlIsActive(item.href, page.url)" :tooltip="item.title">
<Link :href="item.href">
<component :is="item.icon" :class="item.color" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import UserInfo from '@/components/UserInfo.vue';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useSidebar } from '@/components/ui/sidebar';
import { usePage } from '@inertiajs/vue3';
import { ChevronsUpDown } from 'lucide-vue-next';
import UserMenuContent from './UserMenuContent.vue';
import { Button } from '@/components/ui/button'
const page = usePage();
const user = page.props.auth.user;
const { isMobile, state } = useSidebar();
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<!-- data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground -->
<Button size="lg" class="flex items-center gap-2">
<UserInfo :user="user" />
<ChevronsUpDown class="ml-auto size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-(--reka-dropdown-menu-trigger-width) min-w-56 rounded-lg"
:side="isMobile ? 'bottom' : state === 'collapsed' ? 'left' : 'bottom'" align="end" :side-offset="4">
<UserMenuContent :user="user" />
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue';
const patternId = computed(() => `pattern-${Math.random().toString(36).substring(2, 9)}`);
</script>
<template>
<svg class="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" fill="none">
<defs>
<pattern :id="patternId" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
</pattern>
</defs>
<rect stroke="none" :fill="`url(#${patternId})`" width="100%" height="100%"></rect>
</svg>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { LinkComponentBaseProps, Method } from '@inertiajs/core';
import { Link } from '@inertiajs/vue3';
interface Props {
href: LinkComponentBaseProps['href'];
tabindex?: number;
method?: Method;
as?: string;
}
defineProps<Props>();
</script>
<template>
<Link
:href="href"
:tabindex="tabindex"
:method="method"
:as="as"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
<slot />
</Link>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
import { Form } from '@inertiajs/vue3';
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
import { nextTick, onMounted, ref } from 'vue';
const { recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuth();
const isRecoveryCodesVisible = ref<boolean>(false);
const recoveryCodeSectionRef = ref<HTMLDivElement | null>(null);
const toggleRecoveryCodesVisibility = async () => {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
if (isRecoveryCodesVisible.value) {
await nextTick();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
}
};
onMounted(async () => {
if (!recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
});
</script>
<template>
<Card>
<CardHeader>
<CardTitle class="flex gap-3"> <LockKeyhole class="size-4" />2FA Recovery Codes </CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3 select-none sm:flex-row sm:items-center sm:justify-between">
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
<component :is="isRecoveryCodesVisible ? EyeOff : Eye" class="size-4" />
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} Recovery Codes
</Button>
<Form
v-if="isRecoveryCodesVisible"
v-bind="regenerateRecoveryCodes.form()"
method="post"
:options="{ preserveScroll: true }"
@success="fetchRecoveryCodes"
#default="{ processing }"
>
<Button variant="secondary" type="submit" :disabled="processing"> <RefreshCw /> Regenerate Codes </Button>
</Form>
</div>
<div :class="['relative overflow-hidden transition-all duration-300', isRecoveryCodesVisible ? 'h-auto opacity-100' : 'h-0 opacity-0']">
<div class="mt-3 space-y-3">
<div ref="recoveryCodeSectionRef" class="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm">
<div v-if="!recoveryCodesList.length" class="space-y-2">
<div v-for="n in 8" :key="n" class="h-4 animate-pulse rounded bg-muted-foreground/20"></div>
</div>
<div v-else v-for="(code, index) in recoveryCodesList" :key="index">
{{ code }}
</div>
</div>
<p class="text-xs text-muted-foreground select-none">
Each recovery code can be used once to access your account and will be removed after use. If you need more, click
<span class="font-bold">Regenerate Codes</span> above.
</p>
</div>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { PinInput, PinInputGroup, PinInputSlot } from '@/components/ui/pin-input';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor';
import { Form } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { Check, Copy, Loader2, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
interface Props {
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
}
const props = defineProps<Props>();
const isOpen = defineModel<boolean>('isOpen');
const { copy, copied } = useClipboard();
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData } = useTwoFactorAuth();
const showVerificationStep = ref(false);
const code = ref<number[]>([]);
const codeValue = computed<string>(() => code.value.join(''));
const pinInputContainerRef = ref<HTMLElement | null>(null);
const modalConfig = computed<{ title: string; description: string; buttonText: string }>(() => {
if (props.twoFactorEnabled) {
return {
title: 'Two-Factor Authentication Enabled',
description: 'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
};
}
if (showVerificationStep.value) {
return {
title: 'Verify Authentication Code',
description: 'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
};
}
return {
title: 'Enable Two-Factor Authentication',
description: 'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
};
});
const handleModalNextStep = () => {
if (props.requiresConfirmation) {
showVerificationStep.value = true;
nextTick(() => {
pinInputContainerRef.value?.querySelector('input')?.focus();
});
return;
}
clearSetupData();
isOpen.value = false;
};
const resetModalState = () => {
if (props.twoFactorEnabled) {
clearSetupData();
}
showVerificationStep.value = false;
code.value = [];
};
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
}
if (!qrCodeSvg.value) {
await fetchSetupData();
}
},
);
</script>
<template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div class="mb-3 w-auto rounded-full border border-border bg-card p-0.5 shadow-sm">
<div class="relative overflow-hidden rounded-full border border-border bg-muted p-2.5">
<div class="absolute inset-0 grid grid-cols-5 opacity-50">
<div v-for="i in 5" :key="`col-${i}`" class="border-r border-border last:border-r-0" />
</div>
<div class="absolute inset-0 grid grid-rows-5 opacity-50">
<div v-for="i in 5" :key="`row-${i}`" class="border-b border-border last:border-b-0" />
</div>
<ScanLine class="relative z-20 size-6 text-foreground" />
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div class="relative flex w-auto flex-col items-center justify-center space-y-5">
<template v-if="!showVerificationStep">
<div class="relative mx-auto flex max-w-md items-center overflow-hidden">
<div class="relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border border-border">
<div
v-if="!qrCodeSvg"
class="absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center bg-background"
>
<Loader2 class="size-6 animate-spin" />
</div>
<div v-else class="relative z-10 overflow-hidden border p-5">
<div v-html="qrCodeSvg" class="flex aspect-square size-full items-center justify-center" />
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div class="relative flex w-full items-center justify-center">
<div class="absolute inset-0 top-1/2 h-px w-full bg-border" />
<span class="relative bg-card px-2 py-1">or, enter the code manually</span>
</div>
<div class="flex w-full items-center justify-center space-x-2">
<div class="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
<div v-if="!manualSetupKey" class="flex h-full w-full items-center justify-center bg-muted p-3">
<Loader2 class="size-4 animate-spin" />
</div>
<template v-else>
<input type="text" readonly :value="manualSetupKey" class="h-full w-full bg-background p-3 text-foreground" />
<button @click="copy(manualSetupKey || '')" class="relative block h-auto border-l border-border px-3 hover:bg-muted">
<Check v-if="copied" class="w-4 text-green-500" />
<Copy v-else class="w-4" />
</button>
</template>
</div>
</div>
</template>
<template v-else>
<Form v-bind="confirm.form()" reset-on-error @finish="code = []" @success="isOpen = false" v-slot="{ errors, processing }">
<input type="hidden" name="code" :value="codeValue" />
<div ref="pinInputContainerRef" class="relative w-full space-y-3">
<div class="flex w-full flex-col items-center justify-center space-y-3 py-2">
<PinInput id="otp" placeholder="○" v-model="code" type="number" otp>
<PinInputGroup>
<PinInputSlot autofocus v-for="(id, index) in 6" :key="id" :index="index" :disabled="processing" />
</PinInputGroup>
</PinInput>
<InputError :message="errors?.confirmTwoFactorAuthentication?.code" />
</div>
<div class="flex w-full items-center space-x-5">
<Button
type="button"
variant="outline"
class="w-auto flex-1"
@click="showVerificationStep = false"
:disabled="processing"
>
Back
</Button>
<Button type="submit" class="w-auto flex-1" :disabled="processing || codeValue.length < 6"> Confirm </Button>
</div>
</div>
</Form>
</template>
</div>
</DialogContent>
</Dialog>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
import { computed } from 'vue';
interface Props {
user: User;
showEmail?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showEmail: false,
});
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(() => props.user.avatar && props.user.avatar !== '');
</script>
<template>
<Avatar class="h-10 w-10 overflow-hidden rounded-lg">
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
<AvatarFallback class="rounded-full text-black dark:text-white">
{{ getInitials(user.name) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="truncate text-xs text-muted-foreground">{{ user.email }}</span>
</div>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import UserInfo from '@/components/UserInfo.vue';
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { logout } from '@/routes';
import { edit } from '@/routes/profile';
import type { User } from '@/types';
import { Link, router } from '@inertiajs/vue3';
import { LogOut, Settings } from 'lucide-vue-next';
import axios from 'axios';
interface Props {
user: User;
}
const handleLogout = () => {
router.flushAll();
localStorage.removeItem('sanctum_token');
delete axios.defaults.headers.common['Authorization'];
};
defineProps<Props>();
</script>
<template>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserInfo :user="user" :show-email="true" />
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem :as-child="true">
<Link class="block w-full" :href="edit()" prefetch as="button">
<Settings class="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem :as-child="true">
<Link class="block w-full" :href="logout()" @click="handleLogout" as="button" data-test="logout-button">
<LogOut class="mr-2 h-4 w-4" />
Log out
</Link>
</DropdownMenuItem>
</template>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed } from 'vue'
import { type Invoice } from '@/types'
import { toLocalDate, toCurrency } from '@/lib/utils'
import { StatusBadge, statusBadgeLabels, statusBadgeTextColor, castToStatusVariant } from '@/components/ui/status-badge'
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
const props = defineProps<{
invoices: Invoice[],
onItemClicked(invoice: Invoice): void
}>()
const totalPaid = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'paid') amount += invoice.totalAmount
})
return amount
})
const totalTaxPaid = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'paid') amount += calcTaxes(invoice.totalAmount)
})
return amount
})
const totalGrossPaid = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'paid') amount += invoice.totalAmount + calcTaxes(invoice.totalAmount)
})
return amount
})
const totalDue = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (['issued', 'due', 'reminded'].includes(invoice.paymentStatus)) amount += invoice.totalAmount
})
return amount
})
const totalTaxDue = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (['issued', 'due', 'reminded'].includes(invoice.paymentStatus)) amount += calcTaxes(invoice.totalAmount)
})
return amount
})
const totalGrossDue = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (['issued', 'due', 'reminded'].includes(invoice.paymentStatus)) amount += invoice.totalAmount + calcTaxes(invoice.totalAmount)
})
return amount
})
const totalNotIssued = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'draft') amount += invoice.totalAmount
})
return amount
})
const totalTaxNotIssued = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'draft') amount += calcTaxes(invoice.totalAmount)
})
return amount
})
const totalGrossNotIssued = computed(() => {
let amount = 0;
props.invoices.forEach(invoice => {
if (invoice.paymentStatus == 'draft') amount += invoice.totalAmount + calcTaxes(invoice.totalAmount)
})
return amount
})
const totalAmount = computed(() => {
return totalPaid.value + totalDue.value + totalNotIssued.value
})
const totalTax = computed(() => {
return totalTaxPaid.value + totalTaxDue.value + totalTaxNotIssued.value
})
const totalGross = computed(() => {
return totalGrossPaid.value + totalGrossDue.value + totalGrossNotIssued.value
})
const calcTaxes = (amount: number) => {
return Number((0.19 * amount).toFixed(2))
}
</script>
<template>
<Table class="relative document-table">
<TableHeader>
<TableRow class="hover:bg-transparent">
<TableHead class="sticky top-0 w-1/100 lg:w-1/100 hidden md:table-cell">Nr.</TableHead>
<TableHead class="sticky top-0 w-1/100 lg:w-1/20 text-center">Status</TableHead>
<TableHead class="sticky top-0 w-1/100 lg:w-1/20 lg:px-5 text-right hidden md:table-cell">Gestellt
</TableHead>
<TableHead class="sticky top-0 w-1/5 text-right lg:hidden">Rechnung</TableHead>
<TableHead class="sticky top-0 w-1/5 hidden lg:table-cell">Kunde</TableHead>
<TableHead colspan="2" class="sticky top-0 w-1/3 hidden lg:table-cell">Betreff</TableHead>
<TableHead class="sticky top-0 w-1/15 text-right">Netto</TableHead>
<TableHead class="sticky top-0 w-1/15 text-right hidden lg:table-cell">Ust.</TableHead>
<TableHead class="sticky top-0 w-1/15 text-right hidden lg:table-cell">Brutto</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.nr" @click="onItemClicked(invoice)"
class="select-none md:select-auto cursor-default bg-background"
:style="'color:' + statusBadgeTextColor(invoice.paymentStatus)">
<TableCell class="w-1/100 hidden md:table-cell lg:pr-5 tabular-nums">{{ invoice.nr }}
</TableCell>
<TableCell class="w-1/100 text-center">
<StatusBadge :variant="castToStatusVariant(invoice.paymentStatus)">{{
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
</TableCell>
<TableCell class="pr-5 hidden md:table-cell lg:px-5 text-right tabular-nums">{{
toLocalDate(invoice.invoiceDate) }}
</TableCell>
<TableCell class="lg:hidden max-w-[220px] md:max-w-[320px] overflow-hidden text-ellipsis">
<span class="font-bold">{{ invoice.customer?.companyName }}</span><br />
{{ invoice.title }}
</TableCell>
<TableCell
class="hidden lg:table-cell max-w-[100px] md:max-w-[120px] lg:max-w-auto overflow-hidden text-ellipsis font-bold">
{{ invoice.customer?.companyName }}</TableCell>
<TableCell colspan="2"
class="hidden lg:table-cell max-w-[120px] md:max-w-[160px] lg:max-w-auto overflow-hidden text-ellipsis">
{{
invoice.title }}</TableCell>
<TableCell class="text-right tabular-nums">{{ toCurrency(invoice.totalAmount) }}
</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell">{{
toCurrency(calcTaxes(invoice.totalAmount)) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell">{{
toCurrency(invoice.totalAmount + calcTaxes(invoice.totalAmount)) }}</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<!-- Summe -->
<TableRow
class="border-none bg-slate-50 dark:bg-neutral-900/60 hover:bg-slate-50 dark:hover:bg-neutral-900/60">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="3"></TableCell>
<TableCell class="py-4 text-right tabular-nums w-1/100 font-bold">Summe</TableCell>
<TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalAmount) }}</TableCell>
<TableCell class="py-4 text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTax) }}</TableCell>
<TableCell class="py-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGross) }}</TableCell>
</TableRow>
<!-- Bezahlt -->
<TableRow v-if="!(totalDue == 0 && totalNotIssued == 0)"
class="border-none bg-slate-50 dark:bg-neutral-900/60 hover:bg-slate-50 dark:hover:bg-neutral-900/60">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="3"></TableCell>
<TableCell class=" w-1/100 text-right tabular-nums font-bold">Bezahlt</TableCell>
<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=" text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossPaid)
}}</TableCell>
</TableRow>
<TableRow v-if="totalDue > 0"
class="border-none bg-slate-50 dark:bg-neutral-900/60 hover:bg-slate-50 dark:hover:bg-neutral-900/60 text-destructive">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="3"></TableCell>
<TableCell class="text-right tabular-nums w-1/100 font-bold">Offen</TableCell>
<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="text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossDue) }}</TableCell>
</TableRow>
<TableRow v-if="totalNotIssued > 0"
class="border-none bg-slate-50 dark:bg-neutral-900/60 hover:bg-slate-50 dark:hover:bg-neutral-900/60 text-muted-foreground">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell colspan="3"></TableCell>
<TableCell class="text-right tabular-nums w-1/100 font-bold">Nicht gestellt</TableCell>
<TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalNotIssued) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTaxNotIssued) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossNotIssued) }}</TableCell>
</TableRow>
</TableFooter>
</Table>
</template>
<style>
.document-table th,
.document-table td {
padding-top: 0.694em !important;
padding-bottom: 0.694em !important;
}
</style>
@@ -0,0 +1,610 @@
<!-- Rechnungsart: Wiederkehren, einmalig, Abschlag (siehe Chat, wirkt sich auf Leistungszeitraum aus) -->
<!-- TODO: Leistungsdatum -->
<!-- TODO: Leistungsstart -->
<!-- TODO: Leistungsende -->
<!-- TODO: Steuersatz in LineItem -->
<!-- TODO: Stunden und Tagessatz aus Settings -->
<!-- TODO: Client-side validation -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated, useTemplateRef } from "vue"
import { Customer, Invoice, Contact, PaymentTerms, Address } from "@/types"
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
import { toCurrency, toLocalDate, toShortISOString, cn, calcDueDate } from '@/lib/utils';
import axios from 'axios'
import { type DateValue, DateFormatter, getLocalTimeZone, parseDate, fromDate } from "@internationalized/date"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { StatusBadge, statusBadgeLabels, statusBadgeTextColor, StatusBadgeVariants } from '@/components/ui/status-badge'
import LineItemTable from '@/components/documents/LineItemTable.vue'
import { Eye, FileText, CircleEllipsis, Trash2, BookUser, User, CodeXml, CalendarIcon, MessageCircleQuestion, X, CircleX, Logs, ListCheck, ClipboardCheck, ClipboardList } from "lucide-vue-next"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { exportPdf, exportXml } from "@/routes/invoice";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'
const props = defineProps<{
invoiceData: Invoice | null,
customers: Customer[]
modelValue: boolean
}>()
const invoice = ref<Invoice | null>(props.invoiceData)
const paymentTermsData = ref([] as PaymentTerms[])
const isDirty = ref(false);
const isLoading = ref(false);
const importContact = ref(newContact() as Contact)
const importCustomer = ref(newCustomer() as Customer)
const alert = ref({ open: false, title: "", message: "", cancelText: "", onCancel: () => { }, confirmText: "", onConfirm: () => { } })
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
onMounted(async () => {
const response = await axios.get('/api/paymentterms')
paymentTermsData.value = response.data as PaymentTerms[]
})
const isOpen = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
}
})
const value = ref<DateValue>()
// watch for new external invoice data
watch(() => props.invoiceData as Invoice,
(newValue, oldValue) => {
if (newValue == oldValue) return
invoice.value = newValue
// Set initial state for a newly opened document
isDirty.value = false
isLoading.value = true
// console.log("invoiceData", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
}
)
// watch changes on local invoice date
watch(invoice,
(newValue, oldValue) => {
if (isLoading.value) {
// Initial load of invoice data
if (invoice.value && !invoice.value.billingData) {
// Set default billing data from customer
invoice.value.billingData = {
companyName: invoice.value.customer?.companyName || "",
vatId: invoice.value.customer?.vatId || "",
billingAddress: {
lineOne: invoice.value.customer?.billingAddress?.lineOne || "",
lineTwo: invoice.value.customer?.billingAddress?.lineTwo || "",
city: invoice.value.customer?.billingAddress?.city || "",
postalCode: invoice.value.customer?.billingAddress?.postalCode || "",
countryCode: invoice.value.customer?.billingAddress?.countryCode || "",
},
contactFirstName: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].firstName : "",
contactLastName: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].lastName : "",
paymentTerms: invoice.value.customer?.paymentTerms || paymentTermsData.value.length > 0 ? paymentTermsData.value[2] : null,
}
}
if (invoice.value && invoice.value.customer?.id !== 0) {
importCustomer.value = invoice.value.customer as Customer
// console.log("billingData contact", invoice.value?.billingData?.contactFirstName, invoice.value?.billingData?.contactLastName)
invoice.value.customer?.contacts.find(contact => {
if (
contact.firstName === invoice.value?.billingData?.contactFirstName &&
contact.lastName === invoice.value?.billingData?.contactLastName
) {
importContact.value = contact
return true
}
})
}
value.value = fromDate(new Date(invoice.value.invoiceDate), getLocalTimeZone())
}
else {
isDirty.value = true
}
// console.log("invoice", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
},
{ deep: true }
)
watch(importCustomer,
(newValue, oldValue) => {
if (!invoice.value) return
if (!isLoading.value) {
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
invoice.value.billingData.companyName = newValue.companyName
invoice.value.billingData.vatId = newValue.vatId
// Don't overwrite these values during loading
// they can intentionally be different from customer data
if (!invoice.value.billingData.billingAddress || !isLoading.value)
invoice.value.billingData.billingAddress = newValue.billingAddress as Address
if (!invoice.value.billingData.contactFirstName || !isLoading.value)
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
if (!invoice.value.billingData.contactLastName || !isLoading.value)
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
if (!invoice.value.billingData.paymentTerms || !isLoading.value)
invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
if (!isLoading.value) isDirty.value = true
}
},
{ deep: true }
)
watch(importContact,
(newValue, oldValue) => {
if (!invoice.value) return
if (!isLoading.value) {
if (newValue.id !== 0) {
invoice.value.billingData!.contactFirstName = newValue.firstName
invoice.value.billingData!.contactLastName = newValue.lastName
} else {
invoice.value.billingData!.contactFirstName = ''
invoice.value.billingData!.contactLastName = ''
}
isDirty.value = true;
}
},
{ deep: true }
)
onUpdated(() => {
isLoading.value = false;
// console.log("onUpdated", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
})
const saveChanges = () => {
if (invoice.value) {
emit('save', invoice.value)
isOpen.value = false
}
}
const cancelChanges = (event: Event | null) => {
if (isDirty.value) {
alert.value.title = "Wirklich schließen?"
alert.value.message = "Es gibt ungespeicherte Änderungen, die dann verloren gehen."
alert.value.cancelText = "Abbrechen"
alert.value.onCancel = () => {
event?.preventDefault()
event.returnValue = true
alert.value.open = false
}
alert.value.confirmText = "Schließen"
alert.value.onConfirm = () => {
emit('cancel')
isOpen.value = false
alert.value.open = false
}
alert.value.open = true
} else {
emit('cancel')
isOpen.value = false
}
}
const confirmCancel = () => {
return window.confirm('Es gibt ungespeicherte Änderungen. Möchtest Du die Seite wirklich verlassen?');
}
const preview = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id, '_blank')?.focus();
}
const downloadPdf = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/pdf');
}
const downloadXml = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/xml');
}
const deleteInvoice = function () {
let confirm = window.confirm('Möchtest Du diese Rechnung wirklich löschen?')
if (!confirm) return
emit('delete', invoice.value?.id)
isOpen.value = false
}
const updateTotalAmount = () => {
if (!invoice.value) return;
let total = 0;
invoice.value.items.forEach(item => {
total += item.quantity * item.price;
});
invoice.value.totalAmount = total;
}
</script>
<template>
<Dialog id="invoice-dialog" v-model:open="isOpen">
<DialogContent
class="sm:max-w-[min((100%-2rem),1152px)] grid-rows-[auto_minmax(0,1fr)_auto] p-0 h-[calc(100dvh-2rem)]"
@escapeKeyDown="cancelChanges" @interactOutside="cancelChanges">
<DialogHeader class="px-3 pt-3 flex flex-row justify-end">
<DialogTitle class="sr-only">Rechnung</DialogTitle>
<div v-if="invoice && invoice.id > 0" class="hidden md:flex mr-4">
<Button :size="'sm'" :variant="'ghost'" @click="preview">
<Eye :strokeWidth="1.666" class="text-current" />
<span>Vorschau</span>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button :size="'sm'" :variant="'ghost'" @click="downloadPdf">
<FileText :strokeWidth="1.666" class="text-current" />
<span>PDF</span>
</Button>
</TooltipTrigger>
<TooltipContent>
ZUGFeRD
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button :size="'sm'" :variant="'ghost'" @click="downloadXml">
<CodeXml :strokeWidth="1.666" class="text-current" />
<span>XML</span>
</Button>
</TooltipTrigger>
<TooltipContent>
XRechnung
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Sheet as-child class="relativ">
<SheetTrigger>
<Button :size="'sm'" :variant="'ghost'">
<ClipboardList :strokeWidth="1.666" class="text-current" />
<span>Audit</span>
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Are you absolutely sure?</SheetTitle>
<SheetDescription>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
<Button :size="'sm'" :variant="'ghost'" @click="deleteInvoice"
class="text-destructive hover:bg-destructive/5 hover:text-destructive">
<Trash2 :strokeWidth="1.666" class="text-current" />
<span>Löschen</span>
</Button>
</div>
<div class="md:hidden">
<DropdownMenu>
<DropdownMenuTrigger>
<Button :variant="'ghost'" :size="'lg'">
<CircleEllipsis class="size-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="mr-8">
<DropdownMenuItem class="flex justify-between" @click="preview">
<span class="mr-2">Vorschau</span>
<Eye :strokeWidth="1.666" class="text-current" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="flex justify-between" @click="exportPdf">
<div class="mr-2 flex flex-col">
<span>PDF exportieren</span>
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
</div>
<FileText :strokeWidth="1.666" class="text-current" />
</DropdownMenuItem>
<DropdownMenuItem class="flex justify-between" @click="exportXml">
<div class="mr-2 flex flex-col">
<span>XML exportieren</span>
<span class="text-xs text-muted-foreground">(XRechnung)</span>
</div>
<CodeXml :strokeWidth="1.666" class="text-current" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="flex justify-between text-destructive">
<span class="mr-2">Löschen</span>
<Trash2 :strokeWidth="1.666" class="text-current" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DialogHeader>
<div class="overflow-y-auto px-6" v-if="invoice">
<div id="document">
<div id="document-header"
class="block sticky top-0 py-4 bg-slate-100 bg-white dark:bg-neutral-800 z-1 flex items-end gap-12">
<div class="grow">
<h1 class="text-xl text-primary-foreground font-bold" v-if="invoice.id > 0">
Rechnung {{ invoice.nr
}}</h1>
<h1 class="text-xl text-primary-foreground font-bold" v-else>Neue
Rechnung
</h1>
<Input
class="md:text-xl px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
type="text" v-model="invoice.title" placeholder="Titel" />
</div>
<div class="flex flex-col m-0 items-end gap-0">
<span class="text-md text-muted-foreground">{{ toCurrency(invoice.totalAmount) }}</span>
<span class="text-2xl font-bold">{{ toCurrency(Number((invoice.totalAmount *
1.19).toFixed(2))) }}</span>
</div>
</div>
<div id="document-meta"
class="flex-none md:flex gap-12 mt-6 2 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg">
<div class="flex flex-col gap-0">
<div class="flex flex-row gap-4">
<Input type="text" v-model="invoice.billingData.companyName" placeholder="Firma"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Select v-model="importCustomer" by="id">
<SelectTrigger
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 border-none">
<SelectValue>
<BookUser />
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem :value="newCustomer()">
Manuell eingeben
</SelectItem>
<SelectItem v-for="customer in customers" :value="customer">
{{ customer.companyName }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-row gap-4">
<Input type="text" v-model="invoice.billingData.contactFirstName" placeholder="Vorname"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Input type="text" v-model="invoice.billingData.contactLastName" placeholder="Nachname"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Select v-model="importContact" by="id">
<SelectTrigger v-bind:disabled="!importCustomer || invoice.customer.id === 0"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 border-none">
<SelectValue>
<User />
</SelectValue>
</SelectTrigger>
<SelectContent v-if="invoice.customer && invoice.customer.id !== 0">
<SelectItem :value="{ id: 0, firstName: '', lastName: '' }">
</SelectItem>
<SelectItem v-for="customer in importCustomer.contacts" :value="customer">
{{ customer.firstName + ' ' + customer.lastName }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Input type="text" v-model="invoice.billingData.billingAddress.lineOne"
placeholder="Zeile 1"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Input type="text" v-model="invoice.billingData.billingAddress.lineTwo"
placeholder="Zeile 2"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<div class="flex gap-4">
<Input type="text" v-model="invoice.billingData.billingAddress.postalCode"
placeholder="PLZ"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 w-20 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
<Input type="text" v-model="invoice.billingData.billingAddress.city" placeholder="Stadt"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
</div>
<Input type="text" v-model="invoice.billingData.vatId" placeholder="USt-Id-Nr."
class="mt-6 bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50" />
</div>
<div>
<Table>
<TableBody>
<!-- Status -->
<TableRow>
<TableHead class="w-1">Status</TableHead>
<TableCell class="flex items-center gap-4">
<StatusBadge size="sm" :variant="invoice.paymentStatus">{{
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
<Select v-model="invoice.paymentStatus">
<SelectTrigger
class="w-full bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-0 h-8!">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(label, value) in statusBadgeLabels"
:value="value">
<SelectLabel>{{ label }}</SelectLabel>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button v-if="['due', 'reminded'].includes(invoice.paymentStatus)"
:size="'sm'" :variant="'destructive'">Mahnen</Button>
</TableCell>
</TableRow>
<!-- Rechnungsdatum -->
<TableRow>
<TableHead>Datum</TableHead>
<TableCell>
<Input type="date" :modelValue="toShortISOString(invoice.invoiceDate)"
@update:modelValue="newValue => { invoice.invoiceDate = new Date(newValue); invoice.dueDate = calcDueDate(invoice.invoiceDate, invoice.billingData?.paymentTerms?.days) }"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none" />
</TableCell>
</TableRow>
<!-- Zahlungsziel -->
<TableRow>
<TableHead class="flex items-center gap-1">
Zahlungsziel
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageCircleQuestion strokeWidth="1.5"
class="h-[16px] w-[16px] ml-[1px] mb-2" />
</TooltipTrigger>
<TooltipContent>
Zahlungsbedingungen können<br />
beim Kunden hinterlegt werden.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
<TableCell>
<Select v-model="invoice.billingData.paymentTerms" by="id">
<SelectTrigger
class="w-full bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none">
<SelectValue placeholder="Zahlungsziel" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="term in paymentTermsData" :value="term">
<span v-if="term.isFixed">{{ term.description }}</span>
<span v-else>{{ term.days }} Tage</span>
</SelectItem>
</SelectContent>
</Select>
</TableCell>
</TableRow>
<!-- Fällig -->
<TableRow v-if="!invoice.billingData?.paymentTerms?.isFixed">
<TableHead>Fällig</TableHead>
<TableCell class="pl-5">{{ toLocalDate(invoice.dueDate) }}</TableCell>
</TableRow>
<!-- Leistungszeitraum -->
<TableRow>
<TableHead>Leistungszeitraum</TableHead>
<TableCell>
<Input type="date" :modelValue="toShortISOString(invoice.serviceStartDate)"
@update:modelValue="newValue => invoice.serviceStartDate = new Date(newValue)"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none w-fit inline" />
bis
<Input type="date" :modelValue="toShortISOString(invoice.serviceEndDate)"
@update:modelValue="newValue => invoice.serviceEndDate = new Date(newValue)"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none w-fit inline" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<LineItemTable :lineItems="invoice.items" @update:lineItems="updateTotalAmount" />
</div>
</div>
<DialogFooter class="p-6 pt-0 flex-row">
<div :size="'sm'" class="hidden md:block flex-grow-4"></div>
<Button class="grow md:grow-0" @click="cancelChanges">
Abbrechen
</Button>
<Button class="grow md:grow-0" :variant="'action'" @click="saveChanges"
:disabled="!isDirty">Speichern</Button>
</DialogFooter>
<AlertDialog v-model:open="alert.open" :asChild="true">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alert.title }}</AlertDialogTitle>
<AlertDialogDescription>
{{ alert.message }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button v-if="alert.onCancel" @click="alert.onCancel">{{ alert.cancelText }}</Button>
<Button variant="destructive" v-if="alert.onConfirm" @click="alert.onConfirm">{{
alert.confirmText
}}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
</template>
<style>
/* Remove close X */
[data-slot=dialog-content] button.ring-offset-background {
/* display: none; */
border-radius: 100%;
position: absolute;
left: 1rem;
width: 1rem;
height: 1rem;
color: var(--color-destructive);
}
/* Backdrop */
[data-slot=dialog-overlay] {
backdrop-filter: blur(var(--blur-sm));
}
</style>
@@ -0,0 +1,202 @@
<!-- TODO: Mengenfeld Komma als decimal point -->
<!-- Enter in LineItem = neue Zeile -->
<script setup lang="ts">
import { ref, watch, HTMLAttributes } from 'vue'
import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils';
import { LineItem } from '@/types';
import { newLineItem } from '@/types/index.d'
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
import { Input } from '@/components/ui/input';
import { CirclePlus, GripVertical, Trash2, Plus, TextSelect } from 'lucide-vue-next';
import Textarea from '../ui/textarea/Textarea.vue';
import Button from '../ui/button/Button.vue';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
import NumberInput from '../ui/number-input/NumberInput.vue';
import { GrowingTextarea } from '../ui/growing-textarea';
const props = defineProps<{
lineItems: LineItem[],
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
(e: 'update:lineItems', value: LineItem[]): void
}>()
const units = ref(['Stück', 'Stunden', 'Tage', 'pauschal'])
const items = ref(props.lineItems.sort(function (a, b) { return a.position - b.position })) // items only uses props.lineItems as the initial value;
watch(items, (newItems) => {
emit('update:lineItems', newItems)
}, { deep: true })
const newItem = () => {
const position = items.value.length + 1
let item = newLineItem()
item.position = position
items.value.push(item)
}
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 = i + 1
}
}
</script>
<template>
<div :class="cn(props.class)">
<div class="backdrop-blur mt-8 bg-background/80 sticky top-[100px] z-1">
<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-16"></TableHead>
</TableRow>
</TableHeader>
</Table>
</div>
<Table v-if="items.length > 0" class="table-fixed">
<draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost"
@end="recalculatePositions">
<template #item="{ element }">
<TableRow>
<TableCell class="px-0 handle px-1 cursor-move w-6">
<GripVertical :size="18" :strokeWidth="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 h-6 py-0 px-1 m-0 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 mb-1" />
<!-- <Textarea v-model="element.description" placeholder="Beschreibung"
class="py-0 min-h-4 px-1 m-0 bg-transparent dark:bg-transparent border-none placeholder:text-muted-foreground/30 shadow-none" /> -->
<GrowingTextarea v-model="element.description" placeholder="Beschreibung"
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>
<!-- Einh. -->
<TableCell class="w-1/8 text-center">
<Select v-model="element.unit">
<SelectTrigger class="shadow-none bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none pr-0 py-0 pl-1 w-full h-6!">
<SelectValue placeholder="Einheit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="unit in units" :value="unit">
{{ unit }}
</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 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66" />
<NumberFieldInput class="h-6 border-none bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66" />
<NumberFieldIncrement class="text-muted-foreground p-1 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 :modelValue="Number(element.price)" class="bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 h-6 rounded" />
</TableCell>
<!-- Total -->
<TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity)
}}
</TableCell>
<!-- Buttons -->
<TableCell class="w-16 text-right">
<Button :variant="'ghost'" :size="'sm'" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 :size="18" />
</Button>
<Button :variant="'ghost'" :size="'sm'" @click="" class="has-[>svg]:px-1 text-muted-foreground">
<CirclePlus />
</Button>
</TableCell>
</TableRow>
</template>
</draggable>
<TableFooter class="bg-transparent">
<TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8" class="text-center">
<Button class="mt-2" variant="ghost" @click="newItem">
<Plus /> Neue Zeile
</Button>
</TableCell>
</TableRow>
</TableFooter>
</Table>
<Empty v-if="items.length < 1">
<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>
<Button variant="action" @click="newItem">
<Plus /> Neue Zeile
</Button>
</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>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogAction } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogCancel } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogDescription,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from "reka-ui"
import { AlertDialogTrigger } from "reka-ui"
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>
@@ -0,0 +1,9 @@
export { default as AlertDialog } from "./AlertDialog.vue"
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { AlertVariants } from "."
import { cn } from "@/lib/utils"
import { alertVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: AlertVariants["variant"]
}>()
</script>
<template>
<div
data-slot="alert"
:class="cn(alertVariants({ variant }), props.class)"
role="alert"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
>
<slot />
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Alert } from "./Alert.vue"
export { default as AlertDescription } from "./AlertDescription.vue"
export { default as AlertTitle } from "./AlertTitle.vue"
export const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { AvatarRoot } from 'reka-ui'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { AvatarFallback, type AvatarFallbackProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AvatarImageProps } from 'reka-ui'
import { AvatarImage } from 'reka-ui'
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full rounded-full"
>
<slot />
</AvatarImage>
</template>
@@ -0,0 +1,3 @@
export { default as Avatar } from './Avatar.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarImage } from './AvatarImage.vue'
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+26
View File
@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
:class="props.class"
>
<slot />
</nav>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { MoreHorizontal } from 'lucide-vue-next'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-item"
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'reka-ui'
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
as: 'a',
})
</script>
<template>
<Primitive
data-slot="breadcrumb-link"
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ol
data-slot="breadcrumb-list"
:class="cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('text-foreground font-normal', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { ChevronRight } from 'lucide-vue-next'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>
@@ -0,0 +1,7 @@
export { default as Breadcrumb } from './Breadcrumb.vue'
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
export { default as BreadcrumbList } from './BreadcrumbList.vue'
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { type ButtonVariants, buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,38 @@
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium shadow-sm active:shadow-none active:inset-shadow-2xs active:inset-shadow-black/15 active:border-none transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
{
variants: {
variant: {
default:
'bg-slate-100 hover:bg-slate-200 dark:bg-neutral-700 dark:hover:bg-neutral-700/66 border-b-1 border-t-1 border-t-white/75 border-b-slate-300 dark:border-t-neutral-600 dark:border-b-neutral-800',
action:
'bg-blue-600 border-b-1 border-t-1 border-t-blue-400 border-b-blue-800 active:bg-blue-700 hover:bg-blue-500 text-white',
destructive:
'bg-destructive dark:bg-red-700 text-white border-b-1 border-t-1 border-t-red-200 border-b-red-700 dark:border-t-red-400 dark:border-b-red-800 hover:bg-red-600 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
outline:
'border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'border-none shadow-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 px-6 active:pt-[1px] has-[>svg]:px-3',
sm: 'h-7 px-4 rounded-md gap-1.5 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { CalendarRootEmits, CalendarRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="cn('relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>
@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CalendarCellTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCellTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarGridProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGrid, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>
@@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridBodyProps } from "reka-ui"
import { CalendarGridBody } from "reka-ui"
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>
@@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridHeadProps } from "reka-ui"
import { CalendarGridHead } from "reka-ui"
const props = defineProps<CalendarGridHeadProps>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>
@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarGridRowProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGridRow, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</CalendarGridRow>
</template>
@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarHeadCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeadCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell :class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeadCell>
</template>
@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarHeaderProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeader, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeader>
</template>
@@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { CalendarHeadingProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeading, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import { CalendarNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</CalendarNext>
</template>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronLeft } from "lucide-vue-next"
import { CalendarPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</CalendarPrev>
</template>
@@ -0,0 +1,12 @@
export { default as Calendar } from "./Calendar.vue"
export { default as CalendarCell } from "./CalendarCell.vue"
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"
export { default as CalendarGrid } from "./CalendarGrid.vue"
export { default as CalendarGridBody } from "./CalendarGridBody.vue"
export { default as CalendarGridHead } from "./CalendarGridHead.vue"
export { default as CalendarGridRow } from "./CalendarGridRow.vue"
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"
export { default as CalendarHeader } from "./CalendarHeader.vue"
export { default as CalendarHeading } from "./CalendarHeading.vue"
export { default as CalendarNextButton } from "./CalendarNextButton.vue"
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
data-slot="checkbox"
v-bind="forwarded"
:class="
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="flex items-center justify-center text-current transition-none"
>
<slot>
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>
@@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from 'reka-ui'
import { CollapsibleRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot
v-slot="{ open }"
data-slot="collapsible"
v-bind="forwarded"
>
<slot :open="open" />
</CollapsibleRoot>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { CollapsibleContent, type CollapsibleContentProps } from 'reka-ui'
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent
data-slot="collapsible-content"
v-bind="props"
>
<slot />
</CollapsibleContent>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { CollapsibleTrigger, type CollapsibleTriggerProps } from 'reka-ui'
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger
data-slot="collapsible-trigger"
v-bind="props"
>
<slot />
</CollapsibleTrigger>
</template>
@@ -0,0 +1,3 @@
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleContent } from './CollapsibleContent.vue'
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { ComboboxRootEmits, ComboboxRootProps } from "reka-ui"
import { ComboboxRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<ComboboxRootProps>()
const emits = defineEmits<ComboboxRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ComboboxRoot
data-slot="combobox"
v-bind="forwarded"
>
<slot />
</ComboboxRoot>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ComboboxAnchorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxAnchor, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxAnchor
data-slot="combobox-anchor"
v-bind="forwarded"
:class="cn('w-[200px]', props.class)"
>
<slot />
</ComboboxAnchor>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ComboboxEmptyProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxEmpty } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxEmptyProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ComboboxEmpty
data-slot="combobox-empty"
v-bind="delegatedProps"
:class="cn('py-6 text-center text-sm', props.class)"
>
<slot />
</ComboboxEmpty>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ComboboxGroupProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxGroup, ComboboxLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxGroupProps & {
class?: HTMLAttributes["class"]
heading?: string
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ComboboxGroup
data-slot="combobox-group"
v-bind="delegatedProps"
:class="cn('overflow-hidden p-1 text-foreground', props.class)"
>
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ComboboxLabel>
<slot />
</ComboboxGroup>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { ComboboxInputEmits, ComboboxInputProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SearchIcon } from "lucide-vue-next"
import { ComboboxInput, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ComboboxInputProps & {
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<ComboboxInputEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<div
data-slot="command-input-wrapper"
class="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon class="size-4 shrink-0 opacity-50" />
<ComboboxInput
data-slot="command-input"
:class="cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
</ComboboxInput>
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxItem, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ComboboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxItem
data-slot="combobox-item"
v-bind="forwarded"
:class="cn(`data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`, props.class)"
>
<slot />
</ComboboxItem>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ComboboxItemIndicatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxItemIndicator, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxItemIndicatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxItemIndicator
data-slot="combobox-item-indicator"
v-bind="forwarded"
:class="cn('ml-auto', props.class)"
>
<slot />
</ComboboxItemIndicator>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ComboboxContentEmits, ComboboxContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxContent, ComboboxPortal, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<ComboboxContentProps & { class?: HTMLAttributes["class"] }>(), {
position: "popper",
align: "center",
sideOffset: 4,
})
const emits = defineEmits<ComboboxContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxPortal>
<ComboboxContent
data-slot="combobox-list"
v-bind="forwarded"
:class="cn('z-50 w-[200px] rounded-md border bg-popover text-popover-foreground origin-(--reka-combobox-content-transform-origin) overflow-hidden shadow-md outline-none 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
>
<slot />
</ComboboxContent>
</ComboboxPortal>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ComboboxSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ComboboxSeparator
data-slot="combobox-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 h-px', props.class)"
>
<slot />
</ComboboxSeparator>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { ComboboxTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxTrigger
data-slot="combobox-trigger"
v-bind="forwarded"
:class="cn('', props.class)"
tabindex="0"
>
<slot />
</ComboboxTrigger>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ComboboxViewportProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ComboboxViewport, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ComboboxViewportProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxViewport
data-slot="combobox-viewport"
v-bind="forwarded"
:class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)"
>
<slot />
</ComboboxViewport>
</template>
@@ -0,0 +1,12 @@
export { default as Combobox } from "./Combobox.vue"
export { default as ComboboxAnchor } from "./ComboboxAnchor.vue"
export { default as ComboboxEmpty } from "./ComboboxEmpty.vue"
export { default as ComboboxGroup } from "./ComboboxGroup.vue"
export { default as ComboboxInput } from "./ComboboxInput.vue"
export { default as ComboboxItem } from "./ComboboxItem.vue"
export { default as ComboboxItemIndicator } from "./ComboboxItemIndicator.vue"
export { default as ComboboxList } from "./ComboboxList.vue"
export { default as ComboboxSeparator } from "./ComboboxSeparator.vue"
export { default as ComboboxViewport } from "./ComboboxViewport.vue"
export { ComboboxCancel, ComboboxTrigger } from "reka-ui"
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { ContextMenuRootEmits, ContextMenuRootProps } from "reka-ui"
import { ContextMenuRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRoot
data-slot="context-menu"
v-bind="forwarded"
>
<slot />
</ContextMenuRoot>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
ContextMenuCheckboxItem,
ContextMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuCheckboxItem
data-slot="context-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuItemIndicator>
<Check class="size-4" />
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuCheckboxItem>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { ContextMenuContentEmits, ContextMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuContent,
ContextMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
data-slot="context-menu-content"
v-bind="forwarded"
:class="cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class,
)"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuGroupProps } from "reka-ui"
import { ContextMenuGroup } from "reka-ui"
const props = defineProps<ContextMenuGroupProps>()
</script>
<template>
<ContextMenuGroup
data-slot="context-menu-group"
v-bind="props"
>
<slot />
</ContextMenuGroup>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ContextMenuItemEmits, ContextMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<ContextMenuItemProps & {
class?: HTMLAttributes["class"]
inset?: boolean
variant?: "default" | "destructive"
}>(), {
variant: "default",
})
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuItem
data-slot="context-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwarded"
:class="cn(
`focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)"
>
<slot />
</ContextMenuItem>
</template>

Some files were not shown because too many files have changed in this diff Show More