feat: L'Ami Fiduciaire V1.0.0 — full codebase with Story 0.1 complete
Initial commit of the L'Ami Fiduciaire SaaS platform built on Laravel 12, Vue 3, Inertia.js 2, and Tailwind CSS 4. Story 0.1 (rename folders to declarations in database) is implemented and code-reviewed: migration, rollback, and 6 Pest tests all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
168
resources/css/app.css
Normal file
168
resources/css/app.css
Normal file
@@ -0,0 +1,168 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans:
|
||||
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
body,
|
||||
html {
|
||||
--font-sans:
|
||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
}
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
28
resources/js/app.ts
Normal file
28
resources/js/app.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import '../css/app.css';
|
||||
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();
|
||||
30
resources/js/components/AlertError.vue
Normal file
30
resources/js/components/AlertError.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
type Props = {
|
||||
errors: string[];
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: 'Something went wrong.',
|
||||
});
|
||||
|
||||
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="size-4" />
|
||||
<AlertTitle>{{ title }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul class="list-inside list-disc text-sm">
|
||||
<li v-for="(error, index) in uniqueErrors" :key="index">
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
25
resources/js/components/AppContent.vue
Normal file
25
resources/js/components/AppContent.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { SidebarInset } from '@/components/ui/sidebar';
|
||||
|
||||
type 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>
|
||||
283
resources/js/components/AppHeader.vue
Normal file
283
resources/js/components/AppHeader.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
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 { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { getInitials } from '@/composables/useInitials';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
import { dashboard } from '@/routes';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
const auth = computed(() => page.props.auth);
|
||||
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
||||
|
||||
const activeItemStyles =
|
||||
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
|
||||
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="
|
||||
whenCurrentUrl(
|
||||
item.href,
|
||||
activeItemStyles,
|
||||
)
|
||||
"
|
||||
>
|
||||
<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(),
|
||||
whenCurrentUrl(
|
||||
item.href,
|
||||
activeItemStyles,
|
||||
),
|
||||
'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="isCurrentUrl(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>
|
||||
16
resources/js/components/AppLogo.vue
Normal file
16
resources/js/components/AppLogo.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
||||
</div>
|
||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||
<span class="mb-0.5 truncate leading-tight font-semibold"
|
||||
>{{ $page.props.name }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
29
resources/js/components/AppLogoIcon.vue
Normal file
29
resources/js/components/AppLogoIcon.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
type 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>
|
||||
</template>
|
||||
22
resources/js/components/AppShell.vue
Normal file
22
resources/js/components/AppShell.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import type { AppShellVariant } from '@/types';
|
||||
|
||||
type Props = {
|
||||
variant?: AppShellVariant;
|
||||
};
|
||||
|
||||
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>
|
||||
105
resources/js/components/AppSidebar.vue
Normal file
105
resources/js/components/AppSidebar.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { BookOpen, Briefcase, Building2, Folder, HelpCircle, LayoutGrid, Users } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import type { NavItem } from '@/types';
|
||||
import AppLogo from './AppLogo.vue';
|
||||
import { dashboard } from '@/routes';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
|
||||
|
||||
const page = usePage();
|
||||
const mainNavItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
if (page.props.auth?.currentWorkspace) {
|
||||
items.push(
|
||||
{
|
||||
title: 'Clients',
|
||||
href: '/clients',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: 'Dossiers',
|
||||
href: '/folders',
|
||||
icon: Folder,
|
||||
},
|
||||
);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const administrationNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Users',
|
||||
href: '/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Workspaces',
|
||||
href: '/workspaces',
|
||||
icon: Building2,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Tutoriels',
|
||||
href: '#',
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: 'Help Center',
|
||||
href: '#',
|
||||
icon: HelpCircle,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
<WorkspaceSwitcher />
|
||||
<!-- <SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<Link :href="dashboard()">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu> -->
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain :items="mainNavItems" />
|
||||
<template
|
||||
v-if="['admin', 'superadmin'].includes(String($page.props.auth.user?.group ?? ''))"
|
||||
>
|
||||
<NavMain :items="administrationNavItems" label="Administration" />
|
||||
</template>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter :items="footerNavItems" />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<slot />
|
||||
</template>
|
||||
31
resources/js/components/AppSidebarHeader.vue
Normal file
31
resources/js/components/AppSidebarHeader.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||
import NotificationDropdown from '@/components/NotificationDropdown.vue';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}>(),
|
||||
{
|
||||
breadcrumbs: () => [],
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<NotificationDropdown />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
33
resources/js/components/AppearanceTabs.vue
Normal file
33
resources/js/components/AppearanceTabs.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
|
||||
const tabs = [
|
||||
{ value: 'light', Icon: Sun, label: 'Light' },
|
||||
{ value: 'dark', Icon: Moon, label: 'Dark' },
|
||||
{ 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>
|
||||
40
resources/js/components/Breadcrumbs.vue
Normal file
40
resources/js/components/Breadcrumbs.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItemType[];
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
</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>
|
||||
444
resources/js/components/ClientForm.vue
Normal file
444
resources/js/components/ClientForm.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Building2, Plus, Settings, Trash2, User } from 'lucide-vue-next';
|
||||
|
||||
export type ClientContactData = {
|
||||
id?: number;
|
||||
full_name: string;
|
||||
job_title: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
is_principal: boolean;
|
||||
};
|
||||
|
||||
export type ClientFormData = {
|
||||
company_name: string;
|
||||
legal_form: string;
|
||||
ice: string;
|
||||
fiscal_id: string;
|
||||
rc: string;
|
||||
cnss: string;
|
||||
patente: string;
|
||||
contacts: ClientContactData[];
|
||||
internal_responsible_id: number | string | '';
|
||||
status: string;
|
||||
internal_notes: string;
|
||||
};
|
||||
|
||||
type WorkspaceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
form: ClientFormData & {
|
||||
processing: boolean;
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
legalForms: Record<string, string>;
|
||||
clientStatusLabels?: Record<string, string>;
|
||||
workspaceUsers?: WorkspaceUser[];
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
submitLabel: 'Enregistrer',
|
||||
clientStatusLabels: () => ({}),
|
||||
workspaceUsers: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const internalResponsibleSelect = computed({
|
||||
get: () =>
|
||||
props.form.internal_responsible_id === '' ||
|
||||
props.form.internal_responsible_id == null
|
||||
? '__none__'
|
||||
: String(props.form.internal_responsible_id),
|
||||
set: (v: string) => {
|
||||
props.form.internal_responsible_id = v === '__none__' ? '' : v;
|
||||
},
|
||||
});
|
||||
|
||||
function addContact() {
|
||||
props.form.contacts.push({
|
||||
full_name: '',
|
||||
job_title: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
is_principal: props.form.contacts.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function removeContact(index: number) {
|
||||
const wasPrincipal = props.form.contacts[index].is_principal;
|
||||
props.form.contacts.splice(index, 1);
|
||||
if (wasPrincipal && props.form.contacts.length > 0) {
|
||||
props.form.contacts[0].is_principal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function setPrincipal(index: number) {
|
||||
props.form.contacts.forEach((c, i) => {
|
||||
c.is_principal = i === index;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (props.form.internal_responsible_id === '__none__') {
|
||||
props.form.internal_responsible_id = '';
|
||||
}
|
||||
emit('submit');
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Société -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Building2 class="size-4" />
|
||||
Informations société
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Identité légale et données fiscales de l'entreprise
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2 sm:col-span-2">
|
||||
<Label for="company_name">Raison sociale</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
v-model="form.company_name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Nom officiel de l'entreprise"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.company_name"
|
||||
/>
|
||||
<InputError :message="form.errors.company_name" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="legal_form">Forme juridique</Label>
|
||||
<Select
|
||||
v-model="form.legal_form"
|
||||
required
|
||||
:aria-invalid="!!form.errors.legal_form"
|
||||
>
|
||||
<SelectTrigger :class="['w-full', inputClass]">
|
||||
<SelectValue placeholder="Sélectionner..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="(label, value) in legalForms"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="form.errors.legal_form" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="ice">ICE</Label>
|
||||
<Input
|
||||
id="ice"
|
||||
v-model="form.ice"
|
||||
type="text"
|
||||
placeholder="Identifiant Commun de l'Entreprise"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.ice"
|
||||
/>
|
||||
<InputError :message="form.errors.ice" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="fiscal_id"
|
||||
>IF (Identifiant Fiscal)</Label
|
||||
>
|
||||
<Input
|
||||
id="fiscal_id"
|
||||
v-model="form.fiscal_id"
|
||||
type="text"
|
||||
placeholder="Identifiant Fiscal"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.fiscal_id"
|
||||
/>
|
||||
<InputError :message="form.errors.fiscal_id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="rc">RC (Registre de Commerce)</Label>
|
||||
<Input
|
||||
id="rc"
|
||||
v-model="form.rc"
|
||||
type="text"
|
||||
placeholder="Numéro RC"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.rc"
|
||||
/>
|
||||
<InputError :message="form.errors.rc" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="cnss">CNSS</Label>
|
||||
<Input
|
||||
id="cnss"
|
||||
v-model="form.cnss"
|
||||
type="text"
|
||||
placeholder="Numéro d'affiliation"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.cnss"
|
||||
/>
|
||||
<InputError :message="form.errors.cnss" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="patente">Patente</Label>
|
||||
<Input
|
||||
id="patente"
|
||||
v-model="form.patente"
|
||||
type="text"
|
||||
placeholder="Taxe professionnelle"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.patente"
|
||||
/>
|
||||
<InputError :message="form.errors.patente" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Responsables -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<User class="size-4" />
|
||||
Responsables
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Personnes à contacter pour les échanges et dossiers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<InputError :message="form.errors.contacts" />
|
||||
<div
|
||||
v-for="(contact, index) in form.contacts"
|
||||
:key="index"
|
||||
class="space-y-4 rounded-lg border border-sidebar-border/70 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="contact.is_principal"
|
||||
name="principal_contact"
|
||||
class="accent-primary"
|
||||
@change="setPrincipal(index)"
|
||||
/>
|
||||
Principal
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
v-if="form.contacts.length > 1"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive"
|
||||
@click="removeContact(index)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2 sm:col-span-2">
|
||||
<Label :for="`contact_full_name_${index}`"
|
||||
>Nom complet</Label
|
||||
>
|
||||
<Input
|
||||
:id="`contact_full_name_${index}`"
|
||||
v-model="contact.full_name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Prénom Nom"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!form.errors[
|
||||
`contacts.${index}.full_name`
|
||||
]
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
:message="
|
||||
form.errors[
|
||||
`contacts.${index}.full_name`
|
||||
]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label :for="`contact_job_title_${index}`"
|
||||
>Fonction</Label
|
||||
>
|
||||
<Input
|
||||
:id="`contact_job_title_${index}`"
|
||||
v-model="contact.job_title"
|
||||
type="text"
|
||||
placeholder="Ex. Directeur financier"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label :for="`contact_email_${index}`"
|
||||
>Email</Label
|
||||
>
|
||||
<Input
|
||||
:id="`contact_email_${index}`"
|
||||
v-model="contact.email"
|
||||
type="email"
|
||||
placeholder="email@exemple.com"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2 sm:col-span-2">
|
||||
<Label :for="`contact_phone_${index}`"
|
||||
>Téléphone</Label
|
||||
>
|
||||
<Input
|
||||
:id="`contact_phone_${index}`"
|
||||
v-model="contact.phone"
|
||||
type="tel"
|
||||
placeholder="+212 6 00 00 00 00"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="addContact"
|
||||
>
|
||||
<Plus class="mr-1 size-4" />
|
||||
Ajouter un responsable
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Suivi interne -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Settings class="size-4" />
|
||||
Suivi interne
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Attribution et notes réservées au cabinet
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="internal_responsible_id"
|
||||
>Responsable interne</Label
|
||||
>
|
||||
<Select v-model="internalResponsibleSelect">
|
||||
<SelectTrigger :class="['w-full', inputClass]">
|
||||
<SelectValue
|
||||
placeholder="Sélectionner un responsable"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__"
|
||||
>— Aucun —</SelectItem
|
||||
>
|
||||
<SelectItem
|
||||
v-for="user in workspaceUsers"
|
||||
:key="user.id"
|
||||
:value="String(user.id)"
|
||||
>
|
||||
{{ user.name }}
|
||||
<span class="text-muted-foreground">
|
||||
· {{ user.email }}</span
|
||||
>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError
|
||||
:message="form.errors.internal_responsible_id"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="status">Statut</Label>
|
||||
<Select v-model="form.status">
|
||||
<SelectTrigger :class="['w-full', inputClass]">
|
||||
<SelectValue
|
||||
placeholder="Sélectionner un statut"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="(label, value) in clientStatusLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="form.errors.status" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="internal_notes">Notes internes</Label>
|
||||
<textarea
|
||||
id="internal_notes"
|
||||
v-model="form.internal_notes"
|
||||
rows="3"
|
||||
:class="[inputClass, 'min-h-[80px] resize-y']"
|
||||
placeholder="Notes confidentielles sur le client..."
|
||||
:aria-invalid="!!form.errors.internal_notes"
|
||||
/>
|
||||
<InputError :message="form.errors.internal_notes" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
data-test="client-form-submit"
|
||||
>
|
||||
<Spinner v-if="form.processing" class="mr-2 size-4" />
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
114
resources/js/components/DeleteUser.vue
Normal file
114
resources/js/components/DeleteUser.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import Heading from '@/components/Heading.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';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
|
||||
const passwordInput = useTemplateRef('passwordInput');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
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>
|
||||
309
resources/js/components/FolderForm.vue
Normal file
309
resources/js/components/FolderForm.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import type { Form } from '@inertiajs/vue3';
|
||||
import { watch } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
export type FolderFormData = {
|
||||
client_id: number | '';
|
||||
title: string;
|
||||
type: string;
|
||||
period_year: number | string;
|
||||
period_month: number | string;
|
||||
period_quarter: number | string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigned_to: number | '';
|
||||
notes_internal: string;
|
||||
notes_client: string;
|
||||
};
|
||||
|
||||
type Client = {
|
||||
id: number;
|
||||
company_name: string;
|
||||
};
|
||||
|
||||
type WorkspaceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
form: Form<FolderFormData>;
|
||||
folderTypeLabels: Record<string, string>;
|
||||
folderStatusLabels: Record<string, string>;
|
||||
folderPriorityLabels: Record<string, string>;
|
||||
clients: Client[];
|
||||
workspaceUsers: WorkspaceUser[];
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
submitLabel: 'Enregistrer',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = Array.from({ length: 10 }, (_, i) => currentYear - 2 + i);
|
||||
const months = [
|
||||
{ value: '', label: '—' },
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
value: (i + 1).toString(),
|
||||
label: `${i + 1}`,
|
||||
})),
|
||||
];
|
||||
const quarters = [
|
||||
{ value: '', label: '—' },
|
||||
{ value: '1', label: 'T1 (Jan–Mar)' },
|
||||
{ value: '2', label: 'T2 (Avr–Juin)' },
|
||||
{ value: '3', label: 'T3 (Juil–Sep)' },
|
||||
{ value: '4', label: 'T4 (Oct–Déc)' },
|
||||
];
|
||||
|
||||
const isVatMonthly = () => props.form.type === 'vat_monthly';
|
||||
const isVatQuarterly = () => props.form.type === 'vat_quarterly';
|
||||
|
||||
// Clear both period fields when type changes
|
||||
watch(
|
||||
() => props.form.type,
|
||||
() => {
|
||||
props.form.period_month = '';
|
||||
props.form.period_quarter = '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="emit('submit')" class="flex flex-col space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="client_id">Client</Label>
|
||||
<select
|
||||
id="client_id"
|
||||
v-model="form.client_id"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.client_id"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un client</option>
|
||||
<option
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
>
|
||||
{{ client.company_name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.client_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Titre</Label>
|
||||
<Input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ex. Déclaration TVA - T1 2026"
|
||||
aria-invalid="!!form.errors.title"
|
||||
/>
|
||||
<InputError :message="form.errors.title" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="type">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
v-model="form.type"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.type"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un type</option>
|
||||
<option
|
||||
v-for="(label, value) in folderTypeLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.type" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
<div class="grid gap-2">
|
||||
<Label for="period_year">Année</Label>
|
||||
<select
|
||||
id="period_year"
|
||||
v-model="form.period_year"
|
||||
required
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_year"
|
||||
>
|
||||
<option v-for="y in years" :key="y" :value="y">
|
||||
{{ y }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.period_year" />
|
||||
</div>
|
||||
<template v-if="isVatMonthly()">
|
||||
<div class="grid gap-2">
|
||||
<Label for="period_month">Mois</Label>
|
||||
<select
|
||||
id="period_month"
|
||||
v-model="form.period_month"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_month"
|
||||
>
|
||||
<option
|
||||
v-for="m in months"
|
||||
:key="m.value"
|
||||
:value="m.value"
|
||||
>
|
||||
{{ m.label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.period_month" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="isVatQuarterly()">
|
||||
<div class="grid gap-2">
|
||||
<Label for="period_quarter">Trimestre</Label>
|
||||
<select
|
||||
id="period_quarter"
|
||||
v-model="form.period_quarter"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.period_quarter"
|
||||
>
|
||||
<option
|
||||
v-for="q in quarters"
|
||||
:key="q.value"
|
||||
:value="q.value"
|
||||
>
|
||||
{{ q.label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.period_quarter" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="due_date">Date limite</Label>
|
||||
<Input
|
||||
id="due_date"
|
||||
v-model="form.due_date"
|
||||
type="date"
|
||||
aria-invalid="!!form.errors.due_date"
|
||||
/>
|
||||
<InputError :message="form.errors.due_date" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="status">Statut</Label>
|
||||
<select
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.status"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un statut</option>
|
||||
<option
|
||||
v-for="(label, value) in folderStatusLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="priority">Priorité</Label>
|
||||
<select
|
||||
id="priority"
|
||||
v-model="form.priority"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.priority"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option
|
||||
v-for="(label, value) in folderPriorityLabels"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.priority" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="assigned_to">Assigné à</Label>
|
||||
<select
|
||||
id="assigned_to"
|
||||
v-model="form.assigned_to"
|
||||
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-invalid="!!form.errors.assigned_to"
|
||||
>
|
||||
<option :value="''">—</option>
|
||||
<option
|
||||
v-for="user in workspaceUsers"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
>
|
||||
{{ user.name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.assigned_to" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="notes_internal">Notes internes</Label>
|
||||
<textarea
|
||||
id="notes_internal"
|
||||
v-model="form.notes_internal"
|
||||
rows="3"
|
||||
class="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Notes confidentielles"
|
||||
:aria-invalid="!!form.errors.notes_internal"
|
||||
/>
|
||||
<InputError :message="form.errors.notes_internal" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="notes_client">Notes client</Label>
|
||||
<textarea
|
||||
id="notes_client"
|
||||
v-model="form.notes_client"
|
||||
rows="3"
|
||||
class="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Notes partagées avec le client"
|
||||
:aria-invalid="!!form.errors.notes_client"
|
||||
/>
|
||||
<InputError :message="form.errors.notes_client" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
data-test="folder-form-submit"
|
||||
>
|
||||
<Spinner v-if="form.processing" />
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
28
resources/js/components/Heading.vue
Normal file
28
resources/js/components/Heading.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'small';
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'default',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'">
|
||||
<h2
|
||||
:class="
|
||||
variant === 'small'
|
||||
? 'mb-0.5 text-base font-medium'
|
||||
: 'text-xl font-semibold tracking-tight'
|
||||
"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="description" class="text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</header>
|
||||
</template>
|
||||
13
resources/js/components/InputError.vue
Normal file
13
resources/js/components/InputError.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="message">
|
||||
<p class="text-sm text-red-600 dark:text-red-500">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
44
resources/js/components/NavFooter.vue
Normal file
44
resources/js/components/NavFooter.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
type 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>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
39
resources/js/components/NavMain.vue
Normal file
39
resources/js/components/NavMain.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
items: NavItem[];
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup class="px-2 py-0">
|
||||
<SidebarGroupLabel>{{ label || 'Platform' }}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||
<SidebarMenuButton
|
||||
as-child
|
||||
:is-active="isCurrentUrl(item.href)"
|
||||
:tooltip="item.title"
|
||||
>
|
||||
<Link :href="item.href">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
55
resources/js/components/NavUser.vue
Normal file
55
resources/js/components/NavUser.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { ChevronsUpDown } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import UserInfo from '@/components/UserInfo.vue';
|
||||
import UserMenuContent from './UserMenuContent.vue';
|
||||
|
||||
const page = usePage();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
const { isMobile, state } = useSidebar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
data-test="sidebar-menu-button"
|
||||
>
|
||||
<UserInfo :user="user" />
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</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>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
146
resources/js/components/NotificationDropdown.vue
Normal file
146
resources/js/components/NotificationDropdown.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { Bell } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
type NotificationItem = {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
folder_id?: number;
|
||||
folder_title?: string;
|
||||
mentioned_by_name?: string;
|
||||
message?: string;
|
||||
url?: string;
|
||||
};
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type UserNotifications = {
|
||||
unread_count: number;
|
||||
readUrl: string | null;
|
||||
readAllUrl: string | null;
|
||||
items: NotificationItem[] | undefined;
|
||||
};
|
||||
|
||||
const page = usePage();
|
||||
|
||||
const userNotifications = computed<UserNotifications>(() => {
|
||||
return (page.props as Record<string, unknown>).userNotifications as UserNotifications;
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => userNotifications.value?.unread_count ?? 0);
|
||||
const items = computed(() => userNotifications.value?.items ?? []);
|
||||
const isLoading = computed(() => userNotifications.value?.items === undefined);
|
||||
|
||||
function markAsRead(notification: NotificationItem) {
|
||||
const readUrl = userNotifications.value?.readUrl;
|
||||
if (!readUrl) return;
|
||||
|
||||
// Replace __ID__ placeholder with actual notification ID
|
||||
// This is a convention: the server provides a URL template with __ID__ as placeholder
|
||||
const url = readUrl.replace('__ID__', notification.id);
|
||||
router.post(url, {}, { preserveScroll: true });
|
||||
}
|
||||
|
||||
function navigateToNotification(notification: NotificationItem) {
|
||||
const targetUrl = notification.data?.url;
|
||||
if (!targetUrl) return;
|
||||
|
||||
if (!notification.read_at) {
|
||||
markAsRead(notification);
|
||||
}
|
||||
|
||||
router.visit(targetUrl, {
|
||||
onError: () => {
|
||||
// Folder may have been deleted — mark as read anyway
|
||||
if (!notification.read_at) {
|
||||
markAsRead(notification);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function markAllAsRead() {
|
||||
const readAllUrl = userNotifications.value?.readAllUrl;
|
||||
if (!readAllUrl) return;
|
||||
|
||||
router.post(readAllUrl, {}, { preserveScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="relative">
|
||||
<Bell class="size-4" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-80">
|
||||
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div v-if="isLoading" class="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<div v-else-if="!items.length" class="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
Aucune notification.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<DropdownMenuItem
|
||||
v-for="notification in items"
|
||||
:key="notification.id"
|
||||
class="flex cursor-pointer flex-col items-start gap-1 p-3"
|
||||
:class="{ 'opacity-50': notification.read_at }"
|
||||
@click="navigateToNotification(notification)"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium">
|
||||
{{ notification.data?.mentioned_by_name ?? 'Système' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ notification.created_at }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span v-if="notification.data?.folder_title" class="font-medium text-foreground">
|
||||
{{ notification.data.folder_title }}
|
||||
</span>
|
||||
{{ notification.data?.message ? ` — ${notification.data.message}` : '' }}
|
||||
</p>
|
||||
<span
|
||||
v-if="!notification.read_at"
|
||||
class="size-1.5 rounded-full bg-primary"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<DropdownMenuSeparator v-if="items.length > 0" />
|
||||
<DropdownMenuItem
|
||||
v-if="unreadCount > 0"
|
||||
class="justify-center text-xs text-muted-foreground"
|
||||
@click="markAllAsRead"
|
||||
>
|
||||
Marquer tout comme lu
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
143
resources/js/components/Pagination.vue
Normal file
143
resources/js/components/Pagination.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
||||
|
||||
interface PaginationData {
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pagination: PaginationData;
|
||||
selectedCount?: number;
|
||||
perPageOptions?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selectedCount: 0,
|
||||
perPageOptions: () => [10, 25, 50, 100],
|
||||
});
|
||||
|
||||
const canGoPrevious = computed(() => props.pagination.current_page > 1);
|
||||
const canGoNext = computed(() => props.pagination.current_page < props.pagination.last_page);
|
||||
|
||||
const handlePerPageChange = (value: unknown): void => {
|
||||
const str = value != null ? String(value) : null;
|
||||
if (str == null) return;
|
||||
const perPage = Number.parseInt(str, 10);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('per_page', perPage.toString());
|
||||
url.searchParams.set('page', '1');
|
||||
|
||||
router.get(url.pathname + url.search, {}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const goToPage = (page: number): void => {
|
||||
if (page < 1 || page > props.pagination.last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', page.toString());
|
||||
|
||||
router.get(url.pathname + url.search, {}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-muted-foreground text-sm">
|
||||
<span v-if="selectedCount > 0">
|
||||
{{ selectedCount }} sur {{ pagination.total }} ligne(s) sélectionnée(s).
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ pagination.total }} ligne(s) au total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground hidden text-sm sm:inline">Lignes par page</span>
|
||||
<Select
|
||||
:model-value="pagination.per_page.toString()"
|
||||
@update:model-value="handlePerPageChange"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in perPageOptions"
|
||||
:key="option"
|
||||
:value="option.toString()"
|
||||
>
|
||||
{{ option }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground hidden text-sm md:inline">
|
||||
Page {{ pagination.current_page }} sur {{ pagination.last_page }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!canGoPrevious"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<ChevronFirst class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!canGoPrevious"
|
||||
@click="goToPage(pagination.current_page - 1)"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
<div class="text-muted-foreground mx-2 text-sm md:hidden">
|
||||
{{ pagination.current_page }}/{{ pagination.last_page }}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!canGoNext"
|
||||
@click="goToPage(pagination.current_page + 1)"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!canGoNext"
|
||||
@click="goToPage(pagination.last_page)"
|
||||
>
|
||||
<ChevronLast class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
31
resources/js/components/PlaceholderPattern.vue
Normal file
31
resources/js/components/PlaceholderPattern.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue';
|
||||
|
||||
const patternId = `pattern-${useId()}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20"
|
||||
fill="none"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
:id="patternId"
|
||||
x="0"
|
||||
y="0"
|
||||
width="8"
|
||||
height="8"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
stroke="none"
|
||||
:fill="`url(#${patternId})`"
|
||||
width="100%"
|
||||
height="100%"
|
||||
></rect>
|
||||
</svg>
|
||||
</template>
|
||||
25
resources/js/components/TextLink.vue
Normal file
25
resources/js/components/TextLink.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkComponentBaseProps, Method } from '@inertiajs/core';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
type 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>
|
||||
123
resources/js/components/TwoFactorRecoveryCodes.vue
Normal file
123
resources/js/components/TwoFactorRecoveryCodes.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import AlertError from '@/components/AlertError.vue';
|
||||
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';
|
||||
|
||||
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
|
||||
const isRecoveryCodesVisible = ref<boolean>(false);
|
||||
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
|
||||
|
||||
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 class="w-full">
|
||||
<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 && recoveryCodesList.length"
|
||||
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 v-if="errors?.length" class="mt-6">
|
||||
<AlertError :errors="errors" />
|
||||
</div>
|
||||
<div v-else 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>
|
||||
300
resources/js/components/TwoFactorSetupModal.vue
Normal file
300
resources/js/components/TwoFactorSetupModal.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||
import AlertError from '@/components/AlertError.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
|
||||
type Props = {
|
||||
requiresConfirmation: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
const { resolvedAppearance } = useAppearance();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const isOpen = defineModel<boolean>('isOpen');
|
||||
|
||||
const { copy, copied } = useClipboard();
|
||||
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
|
||||
useTwoFactorAuth();
|
||||
|
||||
const showVerificationStep = ref(false);
|
||||
const code = ref<string>('');
|
||||
|
||||
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
|
||||
|
||||
const modalConfig = computed<TwoFactorConfigContent>(() => {
|
||||
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">
|
||||
<AlertError v-if="errors?.length" :errors="errors" />
|
||||
<template v-else>
|
||||
<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"
|
||||
>
|
||||
<Spinner class="size-6" />
|
||||
</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"
|
||||
:style="{
|
||||
filter:
|
||||
resolvedAppearance === 'dark'
|
||||
? 'invert(1) brightness(1.5)'
|
||||
: undefined,
|
||||
}"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<Spinner />
|
||||
</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>
|
||||
|
||||
<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="code" />
|
||||
<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"
|
||||
>
|
||||
<InputOTP
|
||||
id="otp"
|
||||
v-model="code"
|
||||
:maxlength="6"
|
||||
:disabled="processing"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
:index="index - 1"
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<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 || code.length < 6"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
126
resources/js/components/UserForm.vue
Normal file
126
resources/js/components/UserForm.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import type { Form } from '@inertiajs/vue3';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
export type UserFormData = {
|
||||
name: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
password_confirmation?: string;
|
||||
group: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
form: Form<UserFormData>;
|
||||
userGroups: Record<string, string>;
|
||||
showPasswordFields?: boolean;
|
||||
passwordRequired?: boolean;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showPasswordFields: true,
|
||||
passwordRequired: true,
|
||||
submitLabel: 'Save',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="emit('submit')" class="flex flex-col space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="name"
|
||||
placeholder="Full name"
|
||||
aria-invalid="!!form.errors.name"
|
||||
/>
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
aria-invalid="!!form.errors.email"
|
||||
/>
|
||||
<InputError :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div v-if="showPasswordFields" class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:required="passwordRequired"
|
||||
autocomplete="new-password"
|
||||
:placeholder="passwordRequired ? 'Password' : 'Leave blank to keep current'"
|
||||
aria-invalid="!!form.errors.password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" />
|
||||
</div>
|
||||
|
||||
<div v-if="showPasswordFields" class="grid gap-2">
|
||||
<Label for="password_confirmation">Confirm password</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
:required="passwordRequired"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
aria-invalid="!!form.errors.password_confirmation"
|
||||
/>
|
||||
<InputError :message="form.errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="group">Group</Label>
|
||||
<select
|
||||
id="group"
|
||||
v-model="form.group"
|
||||
required
|
||||
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
:aria-invalid="!!form.errors.group"
|
||||
>
|
||||
<option value="" disabled>Select a group</option>
|
||||
<option
|
||||
v-for="(label, value) in userGroups"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.group" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
data-test="user-form-submit"
|
||||
>
|
||||
<Spinner v-if="form.processing" />
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
38
resources/js/components/UserInfo.vue
Normal file
38
resources/js/components/UserInfo.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useInitials } from '@/composables/useInitials';
|
||||
import type { User } from '@/types';
|
||||
|
||||
type 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-8 w-8 overflow-hidden rounded-lg">
|
||||
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg 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>
|
||||
54
resources/js/components/UserMenuContent.vue
Normal file
54
resources/js/components/UserMenuContent.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router } from '@inertiajs/vue3';
|
||||
import { LogOut, Settings } from 'lucide-vue-next';
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import UserInfo from '@/components/UserInfo.vue';
|
||||
import type { User } from '@/types';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
router.flushAll();
|
||||
};
|
||||
|
||||
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 cursor-pointer" :href="edit()" prefetch>
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem :as-child="true">
|
||||
<Link
|
||||
class="block w-full cursor-pointer"
|
||||
:href="logout()"
|
||||
@click="handleLogout"
|
||||
as="button"
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
160
resources/js/components/WorkspaceForm.vue
Normal file
160
resources/js/components/WorkspaceForm.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import type { Form } from '@inertiajs/vue3';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
export type WorkspaceFormData = {
|
||||
name: string;
|
||||
slug: string;
|
||||
user_ids: number[];
|
||||
user_roles: Record<number, string>;
|
||||
};
|
||||
|
||||
export type WorkspaceFormUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
form: Form<WorkspaceFormData>;
|
||||
users: WorkspaceFormUser[];
|
||||
workspaceUserRoles: Record<string, string>;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
submitLabel: 'Save',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const defaultRole = 'member';
|
||||
|
||||
function onUserToggle(userId: number, checked: boolean) {
|
||||
if (checked) {
|
||||
const ids = props.form.user_ids ?? [];
|
||||
if (!ids.includes(userId)) {
|
||||
props.form.user_ids = [...ids, userId];
|
||||
}
|
||||
const roles = props.form.user_roles ?? {};
|
||||
props.form.user_roles = { ...roles, [userId]: roles[userId] ?? defaultRole };
|
||||
} else {
|
||||
props.form.user_ids = (props.form.user_ids ?? []).filter((id) => id !== userId);
|
||||
const roles = { ...(props.form.user_roles ?? {}) };
|
||||
delete roles[userId];
|
||||
props.form.user_roles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleChange(userId: number, role: string) {
|
||||
const roles = props.form.user_roles ?? {};
|
||||
props.form.user_roles = { ...roles, [userId]: role };
|
||||
}
|
||||
|
||||
function isUserSelected(userId: number): boolean {
|
||||
return (props.form.user_ids ?? []).includes(userId);
|
||||
}
|
||||
|
||||
function getUserRole(userId: number): string {
|
||||
return props.form.user_roles?.[userId] ?? defaultRole;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="emit('submit')" class="flex flex-col space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Cabinet Comptable XYZ"
|
||||
aria-invalid="!!form.errors.name"
|
||||
/>
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="slug">Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
placeholder="cabinet-comptable-xyz (optional)"
|
||||
aria-invalid="!!form.errors.slug"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Leave empty to auto-generate from the name.
|
||||
</p>
|
||||
<InputError :message="form.errors.slug" />
|
||||
</div>
|
||||
|
||||
<div v-if="users?.length" class="grid gap-2">
|
||||
<Label>Users</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Select users to add to this workspace.
|
||||
</p>
|
||||
<div
|
||||
class="max-h-48 space-y-2 overflow-y-auto rounded-md border border-sidebar-border/70 p-3 dark:border-sidebar-border"
|
||||
>
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="flex items-center gap-3 rounded px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="user.id"
|
||||
:checked="isUserSelected(user.id)"
|
||||
class="border-input size-4 shrink-0 rounded-[4px] border focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@change="onUserToggle(user.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 flex flex-col">
|
||||
<span class="text-sm font-medium">{{ user.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
user.email
|
||||
}}</span>
|
||||
</div>
|
||||
<select
|
||||
:value="getUserRole(user.id)"
|
||||
:disabled="!isUserSelected(user.id)"
|
||||
class="border-input bg-background h-8 shrink-0 rounded-md border px-2 text-sm disabled:opacity-50"
|
||||
@change="
|
||||
onRoleChange(
|
||||
user.id,
|
||||
($event.target as HTMLSelectElement).value,
|
||||
)
|
||||
"
|
||||
>
|
||||
<option
|
||||
v-for="(label, value) in workspaceUserRoles"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<InputError :message="form.errors.user_ids" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
data-test="workspace-form-submit"
|
||||
>
|
||||
<Spinner v-if="form.processing" />
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
128
resources/js/components/WorkspaceSwitcher.vue
Normal file
128
resources/js/components/WorkspaceSwitcher.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import { BoxSelect, Building2, ChevronsUpDown, Plus } from 'lucide-vue-next';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
const page = usePage();
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
const workspaces = page.props.auth?.workspaces ?? [];
|
||||
const currentWorkspace = page.props.auth?.currentWorkspace ?? null;
|
||||
|
||||
function switchWorkspace(workspace: { id: number }) {
|
||||
router.post('/workspace/switch', { workspace_id: workspace.id }, {
|
||||
preserveState: false,
|
||||
onSuccess: () => router.reload(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu v-if="workspaces.length > 0">
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<Building2 class="size-4" />
|
||||
</div>
|
||||
<div
|
||||
class="grid flex-1 text-left text-sm leading-tight"
|
||||
>
|
||||
<span class="truncate font-medium">
|
||||
{{
|
||||
currentWorkspace?.name ?? 'Select workspace'
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="currentWorkspace"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ currentWorkspace.slug }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
align="start"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="text-xs text-muted-foreground">
|
||||
Workspaces
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="workspace in workspaces"
|
||||
:key="workspace.id"
|
||||
class="gap-2 p-2"
|
||||
@click="switchWorkspace(workspace)"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-sm border"
|
||||
>
|
||||
<Building2 class="size-3.5 shrink-0" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>{{ workspace.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
workspace.slug
|
||||
}}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<!-- <DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<Link
|
||||
href="/workspaces"
|
||||
class="flex w-full cursor-pointer items-center gap-2 p-2"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<span class="font-medium text-muted-foreground">
|
||||
Manage workspaces
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem> -->
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarMenu v-else>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<div
|
||||
class="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg border border-dashed"
|
||||
>
|
||||
<BoxSelect class="size-4" />
|
||||
</div>
|
||||
<span class="text-sm">No workspaces found</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
124
resources/js/components/clients/FolderCalendar.vue
Normal file
124
resources/js/components/clients/FolderCalendar.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
||||
|
||||
type Folder = {
|
||||
id: number;
|
||||
due_date: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folders: Folder[];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const monthNames = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
|
||||
];
|
||||
const dayNames = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||
|
||||
const current = ref(new Date());
|
||||
|
||||
const monthLabel = computed(() =>
|
||||
`${monthNames[current.value.getMonth()]} ${current.value.getFullYear()}`,
|
||||
);
|
||||
|
||||
const datesWithFolders = computed(() => {
|
||||
const set = new Set<string>();
|
||||
props.folders.forEach((f) => {
|
||||
if (f.due_date) set.add(f.due_date);
|
||||
});
|
||||
return set;
|
||||
});
|
||||
|
||||
const foldersByDate = computed(() => {
|
||||
const map = new Map<string, number>();
|
||||
props.folders.forEach((f) => {
|
||||
if (f.due_date) {
|
||||
map.set(f.due_date, (map.get(f.due_date) ?? 0) + 1);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const calendarDays = computed(() => {
|
||||
const year = current.value.getFullYear();
|
||||
const month = current.value.getMonth();
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const startDay = (first.getDay() + 6) % 7;
|
||||
const daysInMonth = last.getDate();
|
||||
|
||||
const days: Array<{ date: Date | null; dateStr: string | null; count: number }> = [];
|
||||
|
||||
for (let i = 0; i < startDay; i++) {
|
||||
days.push({ date: null, dateStr: null, count: 0 });
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(year, month, d);
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
days.push({
|
||||
date,
|
||||
dateStr,
|
||||
count: foldersByDate.value.get(dateStr) ?? 0,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
});
|
||||
|
||||
function prevMonth() {
|
||||
current.value = new Date(current.value.getFullYear(), current.value.getMonth() - 1);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
current.value = new Date(current.value.getFullYear(), current.value.getMonth() + 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-sidebar-border/70 bg-card p-4">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" @click="prevMonth">
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
<span class="text-sm font-medium">{{ monthLabel }}</span>
|
||||
<Button variant="ghost" size="icon" @click="nextMonth">
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1 text-center text-xs">
|
||||
<div
|
||||
v-for="day in dayNames"
|
||||
:key="day"
|
||||
class="py-1 font-medium text-muted-foreground"
|
||||
>
|
||||
{{ day }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, i) in calendarDays"
|
||||
:key="i"
|
||||
class="flex min-h-8 flex-col items-center justify-center rounded-md py-1"
|
||||
:class="[
|
||||
!cell.date
|
||||
? 'invisible'
|
||||
: cell.count > 0
|
||||
? 'bg-primary/15 font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/50',
|
||||
]"
|
||||
>
|
||||
<template v-if="cell.date">
|
||||
{{ cell.date.getDate() }}
|
||||
<span
|
||||
v-if="cell.count > 0"
|
||||
class="mt-0.5 text-[10px]"
|
||||
>
|
||||
{{ cell.count }} dossier{{ cell.count > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
136
resources/js/components/folders/MessageBubble.vue
Normal file
136
resources/js/components/folders/MessageBubble.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, Download, FileText } from 'lucide-vue-next';
|
||||
|
||||
type Props = {
|
||||
message: {
|
||||
id: number;
|
||||
type: string;
|
||||
body: string;
|
||||
sent_by_type: string;
|
||||
sender_name: string;
|
||||
created_at: string;
|
||||
attachments?: Array<{
|
||||
id: number;
|
||||
file_name: string;
|
||||
mime_type?: string;
|
||||
size: string;
|
||||
downloadUrl: string;
|
||||
is_downloaded?: boolean;
|
||||
}>;
|
||||
confirmation_status?: 'pending' | 'confirmed' | 'refused' | null;
|
||||
};
|
||||
messageTypeLabels: Record<string, string>;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
invite: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
situation: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
file_request: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
confirmation: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
|
||||
text: 'bg-slate-100 text-slate-700 dark:bg-slate-800/50 dark:text-slate-300',
|
||||
};
|
||||
|
||||
const confirmationStatusLabels: Record<string, { label: string; class: string }> = {
|
||||
pending: {
|
||||
label: 'En attente',
|
||||
class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
},
|
||||
confirmed: {
|
||||
label: 'Validé',
|
||||
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
},
|
||||
refused: {
|
||||
label: 'Refusé',
|
||||
class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
},
|
||||
};
|
||||
|
||||
function isImageMime(mime?: string | null): boolean {
|
||||
return mime?.startsWith('image/') ?? false;
|
||||
}
|
||||
|
||||
function getTypeColor(type: string): string {
|
||||
return typeColors[type] ?? 'bg-muted text-muted-foreground';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-2xl px-4 py-3"
|
||||
:class="
|
||||
message.sent_by_type === 'user'
|
||||
? 'ml-auto max-w-[85%] bg-muted'
|
||||
: 'mr-auto max-w-[85%] bg-muted/70'
|
||||
"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span class="font-medium">{{ message.sender_name }}</span>
|
||||
<span class="text-muted-foreground">{{ message.created_at }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="getTypeColor(message.type)"
|
||||
>
|
||||
{{ messageTypeLabels[message.type] ?? message.type }}
|
||||
</span>
|
||||
<span
|
||||
v-if="message.confirmation_status"
|
||||
class="inline-flex rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="confirmationStatusLabels[message.confirmation_status]?.class ?? ''"
|
||||
>
|
||||
{{ confirmationStatusLabels[message.confirmation_status]?.label ?? message.confirmation_status }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm">{{ message.body }}</p>
|
||||
<div
|
||||
v-if="message.attachments?.length"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="att in message.attachments"
|
||||
:key="att.id"
|
||||
class="flex items-center gap-2 rounded-lg border border-sidebar-border/70 bg-background/50 p-2"
|
||||
>
|
||||
<div class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<img
|
||||
v-if="isImageMime(att.mime_type)"
|
||||
:src="att.downloadUrl"
|
||||
:alt="att.file_name"
|
||||
class="size-10 object-cover"
|
||||
/>
|
||||
<FileText
|
||||
v-else
|
||||
class="size-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="inline-flex items-center gap-1.5 truncate text-xs font-medium">
|
||||
{{ att.file_name }}
|
||||
<CheckCircle2 v-if="att.is_downloaded" class="size-3.5 text-green-500" />
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{{ att.size }}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
as-child
|
||||
class="shrink-0"
|
||||
>
|
||||
<a
|
||||
:href="att.downloadUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
download
|
||||
@click="att.is_downloaded = true"
|
||||
>
|
||||
<Download class="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
21
resources/js/components/ui/alert/Alert.vue
Normal file
21
resources/js/components/ui/alert/Alert.vue
Normal file
@@ -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>
|
||||
17
resources/js/components/ui/alert/AlertDescription.vue
Normal file
17
resources/js/components/ui/alert/AlertDescription.vue
Normal file
@@ -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>
|
||||
17
resources/js/components/ui/alert/AlertTitle.vue
Normal file
17
resources/js/components/ui/alert/AlertTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
resources/js/components/ui/alert/index.ts
Normal file
24
resources/js/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Alert } from "./Alert.vue"
|
||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||
|
||||
export const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
18
resources/js/components/ui/avatar/Avatar.vue
Normal file
18
resources/js/components/ui/avatar/Avatar.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { AvatarRoot } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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>
|
||||
21
resources/js/components/ui/avatar/AvatarFallback.vue
Normal file
21
resources/js/components/ui/avatar/AvatarFallback.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarFallbackProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AvatarFallback } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</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>
|
||||
16
resources/js/components/ui/avatar/AvatarImage.vue
Normal file
16
resources/js/components/ui/avatar/AvatarImage.vue
Normal file
@@ -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"
|
||||
>
|
||||
<slot />
|
||||
</AvatarImage>
|
||||
</template>
|
||||
3
resources/js/components/ui/avatar/index.ts
Normal file
3
resources/js/components/ui/avatar/index.ts
Normal file
@@ -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"
|
||||
26
resources/js/components/ui/badge/Badge.vue
Normal file
26
resources/js/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
26
resources/js/components/ui/badge/index.ts
Normal file
26
resources/js/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full 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>
|
||||
17
resources/js/components/ui/breadcrumb/Breadcrumb.vue
Normal file
17
resources/js/components/ui/breadcrumb/Breadcrumb.vue
Normal file
@@ -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>
|
||||
23
resources/js/components/ui/breadcrumb/BreadcrumbEllipsis.vue
Normal file
23
resources/js/components/ui/breadcrumb/BreadcrumbEllipsis.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { MoreHorizontal } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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>
|
||||
17
resources/js/components/ui/breadcrumb/BreadcrumbItem.vue
Normal file
17
resources/js/components/ui/breadcrumb/BreadcrumbItem.vue
Normal file
@@ -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>
|
||||
21
resources/js/components/ui/breadcrumb/BreadcrumbLink.vue
Normal file
21
resources/js/components/ui/breadcrumb/BreadcrumbLink.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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>
|
||||
17
resources/js/components/ui/breadcrumb/BreadcrumbList.vue
Normal file
17
resources/js/components/ui/breadcrumb/BreadcrumbList.vue
Normal file
@@ -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>
|
||||
20
resources/js/components/ui/breadcrumb/BreadcrumbPage.vue
Normal file
20
resources/js/components/ui/breadcrumb/BreadcrumbPage.vue
Normal file
@@ -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 { ChevronRight } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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>
|
||||
7
resources/js/components/ui/breadcrumb/index.ts
Normal file
7
resources/js/components/ui/breadcrumb/index.ts
Normal file
@@ -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"
|
||||
29
resources/js/components/ui/button/Button.vue
Normal file
29
resources/js/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { 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>
|
||||
38
resources/js/components/ui/button/index.ts
Normal file
38
resources/js/components/ui/button/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } 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 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-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs 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:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
22
resources/js/components/ui/card/Card.vue
Normal file
22
resources/js/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
resources/js/components/ui/card/CardAction.vue
Normal file
17
resources/js/components/ui/card/CardAction.vue
Normal file
@@ -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>
|
||||
17
resources/js/components/ui/card/CardContent.vue
Normal file
17
resources/js/components/ui/card/CardContent.vue
Normal file
@@ -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>
|
||||
17
resources/js/components/ui/card/CardDescription.vue
Normal file
17
resources/js/components/ui/card/CardDescription.vue
Normal file
@@ -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>
|
||||
17
resources/js/components/ui/card/CardFooter.vue
Normal file
17
resources/js/components/ui/card/CardFooter.vue
Normal file
@@ -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>
|
||||
17
resources/js/components/ui/card/CardHeader.vue
Normal file
17
resources/js/components/ui/card/CardHeader.vue
Normal file
@@ -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>
|
||||
17
resources/js/components/ui/card/CardTitle.vue
Normal file
17
resources/js/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
7
resources/js/components/ui/card/index.ts
Normal file
7
resources/js/components/ui/card/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardAction } from "./CardAction.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
35
resources/js/components/ui/checkbox/Checkbox.vue
Normal file
35
resources/js/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-slot="slotProps"
|
||||
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="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<slot v-bind="slotProps">
|
||||
<Check class="size-3.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
1
resources/js/components/ui/checkbox/index.ts
Normal file
1
resources/js/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
19
resources/js/components/ui/collapsible/Collapsible.vue
Normal file
19
resources/js/components/ui/collapsible/Collapsible.vue
Normal file
@@ -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="slotProps"
|
||||
data-slot="collapsible"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleContentProps } from "reka-ui"
|
||||
import { CollapsibleContent } from "reka-ui"
|
||||
|
||||
const props = defineProps<CollapsibleContentProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleTriggerProps } from "reka-ui"
|
||||
import { CollapsibleTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<CollapsibleTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</CollapsibleTrigger>
|
||||
</template>
|
||||
3
resources/js/components/ui/collapsible/index.ts
Normal file
3
resources/js/components/ui/collapsible/index.ts
Normal file
@@ -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"
|
||||
19
resources/js/components/ui/dialog/Dialog.vue
Normal file
19
resources/js/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
resources/js/components/ui/dialog/DialogClose.vue
Normal file
15
resources/js/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
53
resources/js/components/ui/dialog/DialogContent.vue
Normal file
53
resources/js/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
resources/js/components/ui/dialog/DialogDescription.vue
Normal file
23
resources/js/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
15
resources/js/components/ui/dialog/DialogFooter.vue
Normal file
15
resources/js/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<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="dialog-footer"
|
||||
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
resources/js/components/ui/dialog/DialogHeader.vue
Normal file
17
resources/js/components/ui/dialog/DialogHeader.vue
Normal file
@@ -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="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
resources/js/components/ui/dialog/DialogOverlay.vue
Normal file
21
resources/js/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
59
resources/js/components/ui/dialog/DialogScrollContent.vue
Normal file
59
resources/js/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto 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"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
resources/js/components/ui/dialog/DialogTitle.vue
Normal file
23
resources/js/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
15
resources/js/components/ui/dialog/DialogTrigger.vue
Normal file
15
resources/js/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
10
resources/js/components/ui/dialog/index.ts
Normal file
10
resources/js/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
19
resources/js/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
resources/js/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dropdown-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-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">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...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-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuGroupProps } from "reka-ui"
|
||||
import { DropdownMenuGroup } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup
|
||||
data-slot="dropdown-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}>(), {
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Circle } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-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">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
data-slot="dropdown-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</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>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
18
resources/js/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
18
resources/js/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>()
|
||||
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-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 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuTriggerProps } from "reka-ui"
|
||||
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
16
resources/js/components/ui/dropdown-menu/index.ts
Normal file
16
resources/js/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { default as DropdownMenu } from "./DropdownMenu.vue"
|
||||
|
||||
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
|
||||
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
|
||||
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
|
||||
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
|
||||
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
|
||||
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
|
||||
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
|
||||
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
|
||||
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
|
||||
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
|
||||
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
|
||||
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
|
||||
export { DropdownMenuPortal } from "reka-ui"
|
||||
28
resources/js/components/ui/input-otp/InputOTP.vue
Normal file
28
resources/js/components/ui/input-otp/InputOTP.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { OTPInputEmits, OTPInputProps } from "vue-input-otp"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { useForwardPropsEmits } from "reka-ui"
|
||||
import { OTPInput } from "vue-input-otp"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<OTPInputProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<OTPInputEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OTPInput
|
||||
v-slot="slotProps"
|
||||
v-bind="forwarded"
|
||||
:container-class="cn('flex items-center gap-2 has-disabled:opacity-50', props.class)"
|
||||
data-slot="input-otp"
|
||||
class="disabled:cursor-not-allowed"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</OTPInput>
|
||||
</template>
|
||||
22
resources/js/components/ui/input-otp/InputOTPGroup.vue
Normal file
22
resources/js/components/ui/input-otp/InputOTPGroup.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
v-bind="forwarded"
|
||||
:class="cn('flex items-center', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
resources/js/components/ui/input-otp/InputOTPSeparator.vue
Normal file
21
resources/js/components/ui/input-otp/InputOTPSeparator.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { MinusIcon } from "lucide-vue-next"
|
||||
import { useForwardProps } from "reka-ui"
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const forwarded = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="input-otp-separator"
|
||||
role="separator"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<MinusIcon />
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
32
resources/js/components/ui/input-otp/InputOTPSlot.vue
Normal file
32
resources/js/components/ui/input-otp/InputOTPSlot.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { useForwardProps } from "reka-ui"
|
||||
import { computed } from "vue"
|
||||
import { useVueOTPContext } from "vue-input-otp"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{ index: number, class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
|
||||
const context = useVueOTPContext()
|
||||
|
||||
const slot = computed(() => context?.value.slots[props.index])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-bind="forwarded"
|
||||
data-slot="input-otp-slot"
|
||||
:data-active="slot?.isActive"
|
||||
:class="cn('data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]', props.class)"
|
||||
>
|
||||
{{ slot?.char }}
|
||||
<div v-if="slot?.hasFakeCaret" class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
4
resources/js/components/ui/input-otp/index.ts
Normal file
4
resources/js/components/ui/input-otp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as InputOTP } from "./InputOTP.vue"
|
||||
export { default as InputOTPGroup } from "./InputOTPGroup.vue"
|
||||
export { default as InputOTPSeparator } from "./InputOTPSeparator.vue"
|
||||
export { default as InputOTPSlot } from "./InputOTPSlot.vue"
|
||||
33
resources/js/components/ui/input/Input.vue
Normal file
33
resources/js/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'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',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
</template>
|
||||
1
resources/js/components/ui/input/index.ts
Normal file
1
resources/js/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user