Add initial Code
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import '../css/app.css';
|
||||
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import { initializeTheme } from './composables/useAppearance';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
resolve: (name) => resolvePageComponent(`./pages/${name}.vue`, import.meta.glob<DefineComponent>('./pages/**/*.vue')),
|
||||
setup({ el, App, props, plugin }) {
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.mount(el);
|
||||
},
|
||||
progress: {
|
||||
color: '#4B5563',
|
||||
},
|
||||
});
|
||||
|
||||
// This will set light / dark mode on page load...
|
||||
initializeTheme();
|
||||
@@ -0,0 +1,18 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
withCredentials: true, // Wichtig für Session-Cookies!
|
||||
});
|
||||
|
||||
// Sanctum-Token aus localStorage hinzufügen (falls vorhanden)
|
||||
const token = localStorage.getItem('sanctum_token');
|
||||
if (token) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
export default api;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user