Two month of work

This commit is contained in:
vollstock committed 2026-02-17 10:35:03 +01:00
1 parent 0ffbeeedff
commit d9fd3d1ccb
158 files changed
+5637 -1512

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
<script setup lang="ts">
import AppLayout from '@/layouts/AppLayout.vue';
import { Head } from '@inertiajs/vue3';
</script>
<template>
<Head title="Dashboard" />
<AppLayout title="Dashboard">
</AppLayout>
</template>
+26 -23
View File
@@ -5,7 +5,7 @@ import AppLayout from '@/layouts/AppLayout.vue'
import AppHeader from '@/components/AppHeader.vue'
import { Address, Customer } from '@/types'
import { newCustomer } from '@/types/index.d'
import { bgColorForString } from '@/lib/utils'
import { hotkey, bgColorForString } from '@/lib/utils'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Input } from '@/components/ui/crm-input'
import { Button } from '@/components/ui/crm-button'
@@ -40,6 +40,9 @@ onMounted(() => {
customersData.value = props.customersData.toSorted((a, b) => (a.companyName ?? '').localeCompare(b.companyName ?? ''));
searchField.value = document.getElementById('search')
searchField.value.focus()
// Register hotkeys
hotkey('mod+f', () => { searchField.value.focus() })
})
watch(activeCustomer, () => {
@@ -156,10 +159,10 @@ const call = (number: string, event: Event) => {
<template #left>
<!-- <ButtonGroup aria-label="Button group">
<Button variant="pressed" size="sm">
<LayoutGrid stroke-width="1.5" />
<LayoutGrid />
</Button>
<Button variant="outline" size="sm">
<Rows3 stroke-width="1.5" />
<Rows3 />
</Button>
</ButtonGroup> -->
</template>
@@ -170,11 +173,11 @@ const call = (number: string, event: Event) => {
<Input ref="search-field" id="search" type="text" placeholder="Filtern" class="px-8 bg-background"
v-model="searchQuery" />
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" :stroke-width="1.5" />
<Search class="size-4 text-muted-foreground" />
</span>
<span class="absolute end-0 inset-y-0 flex items-center justify-center px-0 mr-1">
<Button :size="'sm'" :variant="'ghost'" @click="searchQuery = ''; searchField.focus()">
<Delete class="size-4 text-muted-foreground" :stroke-width="1.5" />
<Delete class="size-4 text-muted-foreground" />
</Button>
</span>
</template>
@@ -184,7 +187,7 @@ const call = (number: string, event: Event) => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button size="sm" variant="action" @click="createCustomer">
<Button @click="createCustomer">
<Plus />
Neu
</Button>
@@ -207,7 +210,7 @@ const call = (number: string, event: Event) => {
<div class="columns-xs gap-6">
<Card v-for="customer in filteredCustomers" :key="customer.id"
class="relative mb-6 break-inside-avoid hover:border-slate-300 dark:hover:bg-neutral-800/90 dark:hover:border-neutral-600 overflow-clip"
class="relative mb-6 break-inside-avoid hover:ring-ring/75 hover:ring-[3px] overflow-clip"
@click="editCustomer(customer)">
<CardHeader v-if="customer.logo" class="z-0">
@@ -232,7 +235,7 @@ const call = (number: string, event: Event) => {
<Tooltip v-if="customer.url">
<TooltipTrigger>
<Button variant="ghost" size="sm" @click="browse(customer.url as string, $event)">
<Globe stroke-width="1.5" />
<Globe />
</Button>
</TooltipTrigger>
<TooltipContent>{{ customer.url }}</TooltipContent>
@@ -241,7 +244,7 @@ const call = (number: string, event: Event) => {
<TooltipTrigger>
<Button variant="ghost" size="sm"
@click="showPhoneNumber(customer.phone as string, $event)">
<Phone stroke-width="1.5" />
<Phone />
</Button>
</TooltipTrigger>
<TooltipContent>{{ customer.phone }}</TooltipContent>
@@ -250,7 +253,7 @@ const call = (number: string, event: Event) => {
<TooltipTrigger>
<Button variant="ghost" size="sm"
@click="addressToClipbard(customer.companyName, customer.billingAddress as Address | null, $event)">
<House stroke-width="1.5" />
<House />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -276,9 +279,9 @@ const call = (number: string, event: Event) => {
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent class="p-4 -mr-2" :side-offset="-1">
<TooltipContent class="p-4 -mr-2 text-base" :side-offset="-1">
<p class="font-bold">
<span v-if="contact.academicTitle">{{ contact.academicTitle }}</span>
<span v-if="contact.academicTitle">{{ contact.academicTitle }}&nbsp;</span>
<span>{{ contact.firstName + ' ' + contact.lastName }}</span>
</p>
<p v-if="contact.jobTitle" class="text-muted-foreground">{{ contact.jobTitle }}</p>
@@ -287,17 +290,17 @@ const call = (number: string, event: Event) => {
<!-- E-mail -->
<Button size="sm" v-if="contact.email"
@click="mail(contact.email as string, $event)">
<Mail stroke-width="1.5" @click="" />
<Mail @click="" />
</Button>
<!-- Phone -->
<Button size="sm" v-if="contact.phone"
@click="showPhoneNumber(contact.phone as string, $event)">
<Phone stroke-width="1.5" @click="" />
<Phone @click="" />
</Button>
<!-- Mobile phone -->
<Button size="sm" v-if="contact.mobilePhone"
@click="showPhoneNumber(contact.mobilePhone as string, $event)">
<Smartphone stroke-width="1.5" @click="" />
<Smartphone @click="" />
</Button>
<!-- Online accounts -->
<Button size="sm" v-for="account in contact.onlineAccounts"
@@ -322,24 +325,24 @@ const call = (number: string, event: Event) => {
<!-- Phone number display -->
<Dialog :open="phoneNumber !== ''">
<DialogContent class="w-fit sm:max-w-auto sm:p-8 md:p-9 gap-6 lg:ps-12 lg:gap-9 xl:p-14 xl:gap-12"
@escapeKeyDown="closePhoneNumberDisplay" @interactOutside="closePhoneNumberDisplay">
<DialogTitle class="screen-reader-only hidden">
<!-- sm:p-8 -->
<DialogContent class="w-fit sm:max-w-auto" @escapeKeyDown="closePhoneNumberDisplay"
@interactOutside="closePhoneNumberDisplay">
<DialogTitle class="
md:p-9 lg:ps-12 xl:p-14
text-md sm:text-2xl md:text-3xl lg:text-6xl xl:text-8xl
transition-all whitespace-nowrap proportional-nums lg:tracking-wide font-medium">
<h1>{{ phoneNumber }}</h1>
</DialogTitle>
<DialogDescription class="screen-reader-only hidden">Anrufen</DialogDescription>
<div
class="transition-all text-md sm:text-2xl md:text-3xl lg:text-6xl xl:text-8xl whitespace-nowrap proportional-nums lg:tracking-wide font-medium">
{{ phoneNumber }}</div>
<DialogFooter>
<DialogClose as-child>
<Button @click="closePhoneNumberDisplay">
Schließen
</Button>
<Button @click="call(phoneNumber, $event)" variant="action">
<Phone stroke-width="1.5" />
<Phone />
Anrufen
</Button>
</DialogClose>
+69 -88
View File
@@ -1,19 +1,22 @@
<script setup lang="ts">
import { onMounted, ref } from "vue"
import Heading from '@/components/Heading.vue';
import { onMounted, ref, computed } from "vue"
import AppLayout from '@/layouts/AppLayout.vue';
import { Trophy, ArrowRight, UserCheck2, Repeat, ClipboardCheck } from 'lucide-vue-next';
import { Trophy, ArrowRight, UserCheck2, Repeat, ClipboardCheck, X, ChevronRight } from 'lucide-vue-next';
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'
import Button from '@/components/ui/crm-button/Button.vue';
import { invoices } from '@/routes';
import { toCurrency, toLocalDate, toRoundedCurrency } from '@/lib/utils'
import { Check, Mail, PhoneCall } from "lucide-vue-next"
import { toLocalDate, toRoundedCurrency } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/crm-badge'
import { Link, usePage } from '@inertiajs/vue3';
import axios, { AxiosError } from "axios";
import { toast } from "vue-sonner";
import { Todo } from "@/types";
import Todos from '@/components/Todos.vue';
const salesStatistics = ref({
year: new Date().getFullYear(),
@@ -25,16 +28,18 @@ const salesStatistics = ref({
reminded: 0,
})
// TODO: aus settings
const salesTarget = ref(60000)
const salesTarget = ref(60000) // TODO: aus settings
const todos = ref<Todo[]>([])
const showCompleted = ref(false)
const page = usePage();
const token = page.props.flash?.token
if (token) {
localStorage.setItem('sanctum_token', token)
}
onMounted(async () => {
// Load sales statistics
// Load Todos
try {
let response = await axios.get('/api/todos')
todos.value = response.data
@@ -42,6 +47,7 @@ onMounted(async () => {
toast.error('Fehler beim Laden der Daten', { description: (error as AxiosError).message })
}
// Load sales statistics
try {
let response = await axios.get('/api/invoices/salesStatistics')
salesStatistics.value = response.data
@@ -50,95 +56,73 @@ onMounted(async () => {
}
})
const todos = ref<Todo[]>([])
</script>
<template>
<AppLayout title="Dashboard">
<Heading title="Tooloop Multimedia" description="Kundenpflege und Rechnungswesen" />
<div class="columns-1 lg:columns-2 xl:columns-3 gap-8">
<!-- Nachrichten -->
<div class="break-inside-avoid mb-8 flex flex-col gap-3">
<Alert class="bg-muted">
<Trophy class="h-4 w-4 stroke-amber-600" />
<AlertTitle>Du hast zwei neue Erfolge</AlertTitle>
<AlertDescription class="text-sky-600">
Weiter so
</AlertDescription>
</Alert>
<Alert class="bg-muted">
<UserCheck2 class="h-4 w-4 stroke-lime-600" />
<AlertTitle>Du hast länger keine Bestandskunden mehr kontaktiert</AlertTitle>
<AlertDescription class="text-sky-600">
<a href="">Hier sind ein paar Vorschläge für Dich
<ArrowRight class="inline h-4 w-4" />
</a>
</AlertDescription>
</Alert>
</div>
<!-- TODO: make widget component -->
<!-- Aufgaben -->
<Card class="break-inside-avoid mb-8">
<Card class="break-inside-avoid mb-12">
<CardHeader>
<CardTitle>Aufgaben</CardTitle>
<CardTitle>Benachrichtigungen</CardTitle>
<CardDescription>Card Description</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<Alert class="bg-muted pr-8">
<Trophy class="h-4 w-4 stroke-amber-600" />
<AlertTitle>Du hast zwei neue Erfolge</AlertTitle>
<AlertDescription class="text-sky-600">
Weiter so
</AlertDescription>
<Button variant="ghost" class="rounded-full w-6 h-6 absolute top-0.5 right-0.5">
<X />
</Button>
</Alert>
<Alert class="bg-muted pr-8">
<UserCheck2 class="h-4 w-4 stroke-lime-600" />
<AlertTitle>Du hast länger keine Bestandskunden mehr kontaktiert</AlertTitle>
<AlertDescription class="text-sky-600">
<a href="">Hier sind ein paar Vorschläge für Dich
<ChevronRight class="inline h-4 w-4" />
</a>
</AlertDescription>
<Button variant="ghost" class="rounded-full w-6 h-6 absolute top-0.5 right-0.5">
<X />
</Button>
</Alert>
</CardContent>
<CardFooter>
<Button>Alles leeren</Button>
</CardFooter>
</Card>
<!-- Aufgaben -->
<Card class="break-inside-avoid mb-8">
<CardHeader class="flex justify-between flex-wrap">
<div>
<CardTitle class="mb-1.5">Aufgaben</CardTitle>
<CardDescription class="mb-3">Card Description</CardDescription>
</div>
<div class="flex items-center gap-2">
<Switch id="show-completed" v-model="showCompleted" />
<Label for="show-completed" class="text-muted-foreground">Erledigte</Label>
</div>
</CardHeader>
<CardContent>
<!-- <h3 class="mb-4 font-bold text-md">Verspätet</h3> -->
<!-- TODO: make TodoList component -->
<ul>
<li v-for="todo in todos"
class="flex gap-3 items-baseline border-b-1 last:border-0 border-foreground/20 py-2.5 pl-1 pr-2">
<!-- TODO: make Todo component -->
<!-- Check mark -->
<div
class="relative top-0.75 shrink-0 h-4 aspect-square rounded-full border-muted-foreground has-[input:checked]:border-primary-foreground border flex items-center justify-center">
<div
class="absolute inset-[2px] rounded-full bg-transparent has-[input:checked]:bg-primary-foreground">
<input type="checkbox" class="absolute -inset-2 opacity-0"
:checked="todo.status?.toLowerCase() == 'completed'" :id="todo.id">
</div>
</div>
<!-- Text -->
<div class="grow overflow-hidden truncate">
<!-- Priority -->
<span v-if="todo.priority < 5 && todo.priority > 0"
class="mr-2 text-destructive">!!!</span>
<span v-if="todo.priority == 5" class="mr-2 text-warning-foreground">!!</span>
<span v-if="todo.priority > 5" class="mr-2 text-muted-foreground">!</span>
<!-- Title -->
<label :for="todo.id" class="my-0 px-0 text-base! border-0 outline-0 shadow-none"
:class="{ 'line-through text-muted-foreground': todo.status?.toLowerCase() == 'completed' }">{{
todo.title
}}</label>
<!-- Date -->
<div class="text-sm text-muted-foreground flex gap-3 items-center mt-1">
<span v-if="todo.dueDate">{{ toLocalDate(todo.dueDate) }}</span>
<Repeat v-if="todo.recurring" stroke-width="2" :size="14" />
</div>
</div>
<!-- Icon -->
<div class="relative top-0.75 text-muted-foreground shrink-0">
<PhoneCall v-if="todo.type?.name === 'phoneCall'" stroke-width="1.5" :size="18" />
<ClipboardCheck v-else-if="todo.type?.name === 'todo'" stroke-width="1.5" :size="18" />
<Mail v-else-if="todo.type?.name === 'mail'" stroke-width="1.5" :size="18" />
</div>
</li>
</ul>
<Todos :modelValue="todos" :show-completed="showCompleted" />
</CardContent>
</Card>
@@ -168,14 +152,11 @@ const todos = ref<Todo[]>([])
<CardContent class="relative flex flex-col gap-3">
<div class="flex items-baseline justify-between">
<span class="text-xl font-bold text-primary-foreground">{{
<span class="text-xl font-bold text-primary">{{
toRoundedCurrency(salesStatistics?.paid) || '' }}</span>
<span>{{ toRoundedCurrency(salesTarget) }}</span>
</div>
<!-- <Progress :model-value="66" class="w-full h-10 rounded-lg text-primary-foreground"
data-state="indeterminate" /> -->
<div class="w-full h-8 bg-secondary dark:bg-neutral-900 rounded-md overflow-clip relative flex">
<TooltipProvider :delay-duration="0" v-if="salesStatistics">
<!-- Paid -->
+7 -6
View File
@@ -5,6 +5,7 @@ import { type Invoice } from '@/types'
import { newInvoice } from '@/types/index.d'
import axios from 'axios'
import AppLayout from '@/layouts/AppLayout.vue'
import AppHeader from '@/components/AppHeader.vue'
import { Button } from '@/components/ui/crm-button'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'
import DocumentTable from '@/components/documents/DocumentTable.vue'
@@ -15,11 +16,10 @@ import SelectSeparator from '@/components/ui/select/SelectSeparator.vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { statusBadgeLabels } from '@/components/ui/status-badge'
import AppHeader from '@/components/AppHeader.vue'
import InvoiceDialog from '@/components/documents/InvoiceDialog.vue'
import { hotkey, getPlatformModifierSymbol } from '@/lib/utils'
// initial invoice data from inertia
// Initial invoice data from inertia (see InvoiceController::show)
interface Props {
invoicesData: Invoice[];
}
@@ -49,7 +49,7 @@ onMounted(async () => {
searchField.value = document.getElementById('search')
// register hotkeys
// Register hotkeys
hotkey('n', createInvoice)
hotkey('mod+leftarrow', () => {
if (selectedYearIndex.value < (years.value.length - 1)) {
@@ -87,6 +87,7 @@ const fuse = computed(() => {
'title',
'nr'
],
useExtendedSearch: true,
threshold: 0.3,
}
@@ -211,11 +212,11 @@ const onDeleteInvoice = async (id: number) => {
<Input ref="search-field" id="search" type="text" placeholder="Filtern" class="px-8 bg-background"
v-model="searchQuery" />
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" :stroke-width="1.5" />
<Search class="size-4 text-muted-foreground" />
</span>
<span class="absolute end-0 inset-y-0 flex items-center justify-center px-0 mr-1">
<Button :size="'sm'" :variant="'ghost'" @click="searchQuery = ''; searchField.focus()">
<Delete class="size-4 text-muted-foreground" :stroke-width="1.5" />
<Delete class="size-4 text-muted-foreground" />
</Button>
</span>
</template>
@@ -225,7 +226,7 @@ const onDeleteInvoice = async (id: number) => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button size="sm" variant="action" @click="createInvoice">
<Button @click="createInvoice">
<Plus />
Neu
</Button>
+354
View File
@@ -0,0 +1,354 @@
<script setup lang="ts">
import Heading from '@/components/Heading.vue'
import { Badge } from '@/components/ui/crm-badge/'
import AppLayout from '@/layouts/AppLayout.vue'
import { daysFromNow, isSoon, isToday, toCurrency, toDuration } from '@/lib/utils'
import { Head } from '@inertiajs/vue3'
import { Calendar, ClipboardCheck, MessageCircle, CircleHelp, Plus, Trash2 } from 'lucide-vue-next'
import { ref, computed } from 'vue'
import axios from 'axios'
import draggable from 'vuedraggable'
import { PipelineLane, PipelineItem, Todo } from '@/types'
import { toast } from 'vue-sonner'
import Button from '@/components/ui/crm-button/Button.vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import EditorDialog from '@/components/EditorDialog/EditorDialog.vue'
import TextEditor from '@/components/TextEditor.vue'
import Todos from '@/components/Todos.vue';
import Notes from '@/components/Notes.vue'
import NotesService from '@/services/NotesService'
import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue';
import { alertStore } from '@/stores/alertStore'
import PipelineService from '@/services/PipelineService'
interface Props {
pipeline: PipelineLane[]
}
const props = defineProps<Props>()
const pipeline = ref<PipelineLane[]>(props.pipeline)
const selectedItem = ref<PipelineItem>()
const drag = ref(false)
const dragOptions = ref({
delay: 100, // only start after 100 ms, so we can click items without dragging
delayOnTouchOnly: true,
animation: 200,
easing: "cubic-bezier(0.33, 1, 0.68, 1)", // out quart
group: "cards",
ghostClass: "ghost", // Class name for the drop placeholder
chosenClass: "chosen", // Class name for the chosen item
dragClass: "drag", // Class name for the dragging item
})
const editorDialogOpen = ref(false)
const todos = ref<Todo[]>([])
const alert = alertStore()
const laneSums = computed(() => {
const map: Record<number, number> = {}
pipeline.value.forEach((lane: any) => {
const sum = (lane.items || []).reduce((acc: number, it: any) => {
const v = Number(it.expectedRevenue ?? 0)
return acc + (isNaN(v) ? 0 : v)
}, 0)
map[lane.id] = sum
})
return map
})
// onMounted(() => {
// if (pipeline.value[2] && pipeline.value[2].items)
// editItem(pipeline.value[2].items[1])
// })
const onEndDrag = async function (e: any) {
drag.value = false
const changed: Array<{ id: number; pipelineLaneId: number; position: number }> = []
// Prefer payloads moved/added/removed to decide which lanes to recalc
const laneIds = new Set<number>()
const fromLane = getLaneIdFromEl(e.from)
const toLane = getLaneIdFromEl(e.to)
if (fromLane !== null) laneIds.add(fromLane)
if (toLane !== null) laneIds.add(toLane)
// Recompute positions for affected lanes only
laneIds.forEach((laneId) => {
const lane = pipeline.value.find((l: any) => l.id === laneId)
if (!lane || !Array.isArray(lane.items)) return
lane.items.forEach((item: any, idx: number) => {
const newPos = idx
const newLaneId = laneId
if (item.position !== newPos || item.pipelineLaneId !== newLaneId) {
// update in-memory model
item.position = newPos
item.pipelineLaneId = newLaneId
changed.push({ id: item.id, pipelineLaneId: newLaneId, position: newPos })
}
})
})
if (changed.length === 0) return
try {
await persistPositions(changed)
} catch (e) {
// on failure, consider reloading or informing the user
// simple approach: reload canonical data
toast.error('Could not save positions, reload recommended', { description: (e as Error).message })
}
}
const persistPositions = async (changed: Array<{ id: number; pipelineLaneId: number; position: number }>) => {
if (!changed.length) return
try {
await axios.post('/api/pipeline/positions', changed)
} catch (err: Error) {
toast.error('Failed to persist pipeline positions', { description: err.message })
throw err
}
}
const getLaneIdFromEl = (el: any): number | null => {
try {
const v = el?.dataset?.laneId
return v ? Number(v) : null
} catch (e) {
return null
}
}
const cardClasses = (item: PipelineItem): string => {
// Due date
if (item.dueDate && daysFromNow(item.dueDate) < 0) return "border-l-4 border-destructive"
else if (item.dueDate && isSoon(item.dueDate)) return "border-l-4 border-warning"
return ""
}
const badgeVariant = (item: PipelineItem): "default" | "secondary" | "destructive" | "warning" | "outline" | null | undefined => {
// Due date
if (item.dueDate && daysFromNow(item.dueDate) < 0) return "destructive"
else if (item.dueDate && isSoon(item.dueDate)) return "warning"
return "secondary"
}
const editItem = async (item: PipelineItem) => {
// Load Todos
// try {
// let response = await axios.get('/api/todos')
// todos.value = response.data
// } catch (error) {
// toast.error('Fehler beim Laden der Daten', { description: (error as AxiosError).message })
// }
// Load notes lazily
if (item.id !== 0 && (item.notes === undefined || item.notes.length === 0)) {
NotesService.getAllNotes('PipelineItem', item.id).then(notes => {
if (notes) item.notes = notes
})
}
selectedItem.value = item
editorDialogOpen.value = true
}
const deleteItem = (item: PipelineItem | undefined) => {
if (item === undefined) return
alert.show(
"Möchtest Du diese Karte wirklich löschen?", null,
{
actionText: "Löschen",
actionVariant: "destructive",
onAction: async () => {
PipelineService.deletePipelineItem(item.id).then(deleted => {
if (deleted) {
let lane = pipeline.value.findIndex(lane => lane.id === item.pipelineLaneId)
if (lane === -1) return
pipeline.value[lane].items = pipeline.value[lane].items?.filter(i => i.id !== item.id)
editorDialogOpen.value = false
}
})
}
}
)
}
</script>
<template>
<Head title="Vertriebspipeline" />
<AppLayout title="Vertriebspipeline">
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<Heading title="Vertriebspipeline" description="Mache interessante Kontakte zu Kunden"
icon="Heading" />
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent class="max-w-100">
<p>Eine Vetriebspipeline ist ein visuelles und strukturelles System, das den Fortschritt
potenzieller Kunden – von der ersten Kontaktaufnahme (Akquise) bis zum
abgeschlossenen
Verkauf (Projekt) – abbildet. Sie hilft Teams, Chancen zu priorisieren, nächste
Schritte
zu planen und den Vertriebsprozess transparent zu gestalten.</p>
<p class="mt-2">Mehr zum Thema <a href="#"
class="text-blue-500 underline hover:text-blue-700">Vertrieb in Caramel</a></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button>
<Plus /> Neue Karte
</Button>
</div>
<!-- Horizontal scroll container -->
<div class="overflow-x-auto overflow-y-hidden grow flex flex-col max-h-full relative">
<!-- ------------------------------------------------------- -->
<!-- Pipeline -->
<!-- ------------------------------------------------------- -->
<div
class="flex items-center h-16 font-semibold border shadow-md bg-background rounded-lg z-10 absolute min-w-full">
<div v-for="(lane, index) in pipeline"
class="flex items-center justify-between min-w-64 relative pl-6 pr-6"
:style="'width: ' + 100 / pipeline.length + '%'">
<div class="flex flex-col grow">
<h3 class="text-primary font-semibold">{{ lane.title }}</h3>
<span class="text-sm">{{ toCurrency(laneSums[lane.id] || 0) }}</span>
</div>
<Badge class="rounded-full text-tiny aspect-square p-2.5 h-lh" variant="secondary">
{{ lane.items?.length }}</Badge>
<svg v-if="index < pipeline.length - 1" viewBox="0 0 19 48" xmlns="http://www.w3.org/2000/svg"
class="h-16 absolute -right-2 z-1">
<path style="fill:none;stroke:var(--border);stroke-width:1;" d="M 0,0 18,24 0,48" />
</svg>
</div>
</div>
<!-- ------------------------------------------------------- -->
<!-- Card lanes -->
<!-- ------------------------------------------------------- -->
<div class="flex max-h-full">
<draggable v-for="(lane, index) in pipeline"
class="list-group flex flex-col gap-4 px-4 pt-22 pb-8 overflow-y-auto flex-1 min-w-64 border-x-4 border-main"
:class="{ 'bg-sidebar/40': index % 2 > 0 }" :list="lane.items" v-bind="dragOptions"
:data-lane-id="lane.id" @start="" @change="" @end="onEndDrag" itemKey="id">
<template #item="{ element: item }">
<div class="list-group-item bg-background shadow rounded-lg flex flex-col gap-3 p-3 hover:ring-ring hover:ring-[3px] active:bg-accent"
:class="cardClasses(item)" @click="editItem(item)" @dblclick="">
<h4 class="text font-semibold">{{ item.title }}</h4>
<p class="text-sm text-muted-foreground" v-if="item.expectedRevenue > 0">{{
toCurrency(item.expectedRevenue) }}</p>
<div class="flex items-center gap-2 flex-wrap">
<Badge variant="secondary" v-if="item.actions">
<ClipboardCheck /> {{ item.actions }}
</Badge>
<Badge v-if="item.dueDate" :variant="badgeVariant(item)">
<Calendar />
<span v-if="isToday(item.dueDate)">Heute</span>
<span v-else>{{ toDuration(item.dueDate) }}</span>
</Badge>
<Badge variant="secondary" v-if="item.notes && item.notes.length > 0">
<MessageCircle /> {{ item.notes.length }}
</Badge>
<Badge variant="secondary" v-else-if="item.notesCount > 0">
<MessageCircle /> {{ item.notesCount }}
</Badge>
</div>
</div>
</template>
</draggable>
</div>
</div>
</div>
<!-- --------------------------------------------------------------- -->
<!-- Editor dialog -->
<!-- --------------------------------------------------------------- -->
<EditorDialog :title="selectedItem?.title" v-model="editorDialogOpen">
<!-- <template v-slot:description>
Welt
</template> -->
<!-- <template v-slot:buttons>
</template> -->
<template v-slot:ellipsisMenuItems>
<DropdownMenuItem
class="flex justify-between text-destructive! hover:bg-destructive! hover:text-destructive-foreground!"
@click="deleteItem(selectedItem)">
<div class="flex items-center gap-3">
<Trash2 class="text-current" />
<span class="mr-2">Löschen</span>
</div>
</DropdownMenuItem>
</template>
<template v-slot:content>
<TextEditor :model-value="selectedItem?.description" @change:model-value="console.log"
ref="description-editor" />
<Notes v-if="selectedItem" title="Protokoll" :notableId="selectedItem.id" notableType="PipelineItem"
:modelValue="selectedItem.notes" />
</template>
<template v-slot:sidebar>
<NumberInput label="Erwarteter Umsatz" :modelValue="selectedItem?.expectedRevenue as number" suffix=" €"
@update:model-value="console.log" />
<Todos :modelValue="todos" :show-completed="false" />
</template>
</EditorDialog>
</AppLayout>
</template>
<style scoped>
.list-group-item {
cursor: default;
}
.ghost {
visibility: hidden;
}
.chosen {
transition: transform 200ms, box-shadow 200ms;
transition-delay: 200ms;
box-shadow: var(--shadow-xl);
transition-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1);
transform: rotate(-1deg) scale(1.05);
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import AppLayout from '@/layouts/AppLayout.vue';
import { Table, TableCell, TableFooter, TableBody, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Table, TableCell, TableFooter, TableBody, TableHead, TableHeader, TableRow, } from '@/components/ui/crm-table';
</script>
<template>
+3 -3
View File
@@ -124,11 +124,11 @@ const toggleAllCategories = () => {
<Input ref="search-field" id="search" type="text" placeholder="Filtern" class="px-8 bg-background"
v-model="searchQuery" />
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" :stroke-width="1.5" />
<Search class="size-4 text-muted-foreground" />
</span>
<span class="absolute end-0 inset-y-0 flex items-center justify-center px-0 mr-1">
<Button :size="'sm'" :variant="'ghost'" @click="searchQuery = ''; searchField.focus()">
<Delete class="size-4 text-muted-foreground" :stroke-width="1.5" />
<Delete class="size-4 text-muted-foreground" />
</Button>
</span>
</template>
@@ -137,7 +137,7 @@ const toggleAllCategories = () => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button size="sm" variant="action" @click="">
<Button @click="">
<Plus />
Neu
</Button>
+338 -1
View File
@@ -1,6 +1,199 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import Button from '@/components/ui/crm-button/Button.vue';
import Input from '@/components/ui/crm-input/Input.vue';
import Progress from '@/components/ui/progress/Progress.vue';
import { Table, TableRow, TableBody, TableCell, TableFooter, TableHead, TableHeader } from '@/components/ui/crm-table';
import { Avatar, AvatarFallback, AvatarImage, } from '@/components/ui/avatar'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { useInitials } from '@/composables/useInitials';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head } from '@inertiajs/vue3';
import { Head, usePage } from '@inertiajs/vue3';
import { Ellipsis, Menu, Plus, ReceiptEuro, Trash2 } from 'lucide-vue-next';
import { isAfter, isBefore, toLocalDate, toShortISOString, toShortLocalDate } from '@/lib/utils';
import { Timesheet, TimesheetEntry, Invoice, LineItem } from '@/types'
import { newTimesheet, newTimesheetEntry, newInvoice, newLineItem } from '@/types/index.d'
import TimesheetService from '@/services/TimesheetService';
import { toast } from 'vue-sonner';
import { Checkbox } from '@/components/ui/crm-checkbox';
import { alertStore } from "@/stores/alertStore"
import Editable from '@/components/ui/crm-editable/Editable.vue';
import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue';
import axios, { AxiosError } from "axios"
interface Props {
timesheetData: Timesheet[];
}
const props = defineProps<Props>();
const timesheets = ref([] as Timesheet[])
const page = usePage();
const auth = computed(() => page.props.auth);
const alert = alertStore()
const { getInitials } = useInitials();
const selectedTimesheet = ref(-1);
const hourFormatter = new Intl.NumberFormat('de-DE', {
style: 'decimal'
})
onMounted(() => {
timesheets.value = props.timesheetData
if (timesheets.value.length >= 0) selectTimesheet(timesheets.value[0])
})
const selectTimesheet = (timesheet: Timesheet) => {
if (timesheets.value.length == 0) return
selectedTimesheet.value = timesheets.value.findIndex(ts => ts.id === timesheet.id)
if (!timesheet.entries) {
TimesheetService.getTimesheetEntries(timesheet.id).then((entries) => {
timesheets.value[selectedTimesheet.value].entries = entries ?? []
}).catch((error) => {
toast.error('Fehler beim Laden der Einträge', { duration: 5000, description: error.message })
})
}
}
const createTimesheet = () => {
const timesheet = newTimesheet()
timesheets.value.unshift(timesheet)
TimesheetService.createTimesheet(timesheet).then((timesheet) => {
let index = timesheets.value.findIndex(ts => ts.id == 0)
timesheets.value[index] = timesheet
}).catch((error) => {
timesheets.value = timesheets.value.filter(t => t.id == 0)
toast.error('Fehler beim Erstellen des Stundenzettels', { duration: 5000, description: error.message })
})
}
const updateTimesheet = () => {
if (selectedTimesheet.value < 0) return
TimesheetService.updateTimesheet(timesheets.value[selectedTimesheet.value]).catch((error) => {
toast.error('Fehler beim Speichern des Stundenzettels', { duration: 5000, description: error.message })
})
}
const deleteTimesheet = (timesheet: Timesheet) => {
alert.show(
"Möchtest Du diesen Stundenzettel wirklich löschen?",
"Alle Einträge gehen dann ebenfalls verloren.",
{
actionText: "Löschen",
actionVariant: "destructive",
onAction: async () => {
TimesheetService.deleteTimesheet(timesheet.id).then(() => {
timesheets.value = timesheets.value.filter(t => t.id != timesheet.id)
selectedTimesheet.value = timesheets.value.length > 0 ? 0 : -1
}).catch((error) => {
toast.error('Fehler beim Löschen des Stundenzettels', { duration: 5000, description: error.message })
})
}
}
)
}
const addEntry = () => {
const entry = newTimesheetEntry();
entry.user = auth.value.user
entry.timesheetId = timesheets.value[selectedTimesheet.value].id
entry.userId = page.props.auth.user.id
TimesheetService.createEntry(entry).then((response) => {
timesheets.value[selectedTimesheet.value].entries?.push(response)
}).catch((error) => {
toast.error('Fehler beim Erstellen des Eintrags', { duration: 5000, description: error.message })
})
}
const deleteEntry = (entry: TimesheetEntry) => {
TimesheetService.deleteEntry(entry.id).then((response) => {
timesheets.value[selectedTimesheet.value].entries?.splice(timesheets.value[selectedTimesheet.value].entries?.indexOf(entry), 1)
}).catch((error) => {
toast.error('Fehler beim Löschen des Eintrags', { duration: 5000, description: error.message })
})
}
const updateEntry = (entry: TimesheetEntry) => {
TimesheetService.updateEntry(entry).then(() => {
}).catch((error) => {
toast.error('Fehler beim Speichern des Eintrags', { duration: 5000, description: error.message })
})
}
const timesheetSummary = computed(() => {
return (ts: Timesheet) => {
const entries = ts.entries ?? []
if (entries.length > 0) {
const totalHours = entries.reduce((sum, e) => sum + (e.hours ?? 0), 0)
const hoursBilled = entries.reduce((sum, e) => sum + ((e.billed ? (e.hours ?? 0) : 0)), 0)
let earliestDate: string | null = null
let latestDate: string | null = null
for (const e of entries) {
if (!e.date) continue
if (!earliestDate || new Date(e.date) < new Date(earliestDate)) earliestDate = e.date
if (!latestDate || new Date(e.date) > new Date(latestDate)) latestDate = e.date
}
return {
totalHours,
hoursBilled,
earliestDate,
latestDate,
}
}
// Fallback to API-provided summary fields until entries are loaded
return {
totalHours: ts.totalHours ?? 0,
hoursBilled: ts.hoursBilled ?? 0,
earliestDate: ts.earliestDate ?? null,
latestDate: ts.latestDate ?? null,
}
}
})
const createInvoice = async () => {
let billedEntries: TimesheetEntry[] = []
let invoice: Invoice = newInvoice()
invoice.customer = null
invoice.billingData = null
timesheets.value[selectedTimesheet.value].entries?.forEach((entry => {
if (entry.billed) return
let lineItem: LineItem = newLineItem(false)
lineItem.title = toLocalDate(entry.date)
lineItem.description = entry.description || ''
lineItem.quantity = entry.hours
lineItem.price = 750 / 8 // TODO: get rate from settings
if (!invoice.serviceStartDate || isBefore(entry.date, invoice.serviceStartDate)) {
invoice.serviceStartDate = new Date(entry.date)
}
if (!invoice.serviceEndDate || isAfter(entry.date, invoice.serviceEndDate)) {
invoice.serviceEndDate = new Date(entry.date)
}
invoice.items.push(lineItem)
billedEntries.push(entry)
}))
try {
const response = await axios.post('/api/invoices', invoice).then();
billedEntries.forEach(entry => {
entry.billed = true
updateEntry(entry)
})
let newInvoice = response.data
// got to invoices?
console.log(newInvoice)
} catch (e) {
toast.error('Fehler beim erzeugen der Rechnung', { description: (e as AxiosError).message })
}
}
</script>
<template>
@@ -8,5 +201,149 @@ import { Head } from '@inertiajs/vue3';
<Head title="Zeiterfassung" />
<AppLayout title="Zeiterfassung">
<div class="pl-90 print:pl-0">
<div class="flex items-center justify-between mb-8">
<Editable v-if="timesheets[selectedTimesheet]" v-model="timesheets[selectedTimesheet].title"
@change:model-value="value => { timesheets[selectedTimesheet].title = value; updateTimesheet(); }"
default-value="Zeiterfassung" placeholder="Zeiterfassung"
class="text-lg! text-primary font-semibold" />
<h2 v-else class="text-lg text-primary font-semibold">Zeiterfassung</h2>
<DropdownMenu v-if="selectedTimesheet >= 0">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="print:hidden">
<Ellipsis class="visible-mac" />
<Menu class="visible-pc" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<!-- Delete -->
<DropdownMenuItem class="flex justify-between" @click="createInvoice">
<div class="flex items-center gap-3">
<ReceiptEuro class="text-current" />
<span class="mr-2">Rechnung stellen</span>
</div>
</DropdownMenuItem>
<!-- Delete -->
<DropdownMenuItem
class="flex justify-between text-destructive! hover:bg-destructive! hover:text-destructive-foreground!"
@click="deleteTimesheet(timesheets[selectedTimesheet])">
<div class="flex items-center gap-3">
<Trash2 class="text-current" />
<span class="mr-2">Löschen</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Table class="relative">
<TableHeader>
<TableRow>
<TableHead class="w-1/100">Abger.</TableHead>
<TableHead class="w-1/6">Datum</TableHead>
<TableHead class="w-1/6">Mitarbeiter</TableHead>
<TableHead class="w-1">Beschreibung</TableHead>
<TableHead class="w-1/100 text-right">Dauer</TableHead>
<TableHead class="w-1/100 print:hidden"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="selectedTimesheet >= 0 && timesheets[selectedTimesheet].entries"
v-for="entry in timesheets[selectedTimesheet].entries" class="bg-background">
<TableCell class="p-0">
<Checkbox v-model="entry.billed" class="text-success mx-auto"
@update:model-value="updateEntry(entry)" />
</TableCell>
<TableCell>
<Input type="date" :model-value="toShortISOString(entry.date)"
@update:model-value="value => { entry.date = value as string; updateEntry(entry); }" />
</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<Avatar class="size-6">
<AvatarImage :src="'storage/uploads/users/' + entry.user?.avatar"
:alt="entry.user?.name" />
<AvatarFallback class="rounded-full bg-primary text-black dark:text-white">
{{ getInitials(entry.user?.name) }}
</AvatarFallback>
</Avatar>
{{ entry.user?.name }}
</div>
</TableCell>
<TableCell>
<Editable v-model="entry.description as string" placeholder="Beschreibung"
@change:model-value="value => { entry.description = value; updateEntry(entry); }" />
</TableCell>
<TableCell class="text-right">
<NumberInput v-model="entry.hours" suffix=" h" :minimumFractionDigits="0"
@update:model-value="updateEntry(entry)" />
</TableCell>
<TableCell class="print:hidden">
<Button size="icon" variant="ghost" @click="deleteEntry(entry)">
<Trash2 class="size-4 text-muted-foreground" />
</Button>
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow class="font-bold">
<TableCell colspan="4" class="text-center">
<Button variant="ghost" @click="addEntry" class="print:hidden">
<Plus class="text-muted-foreground" /> Zeile einfügen
</Button>
</TableCell>
<TableCell class="text-right" v-if="selectedTimesheet >= 0">{{
hourFormatter.format(timesheetSummary(timesheets[selectedTimesheet]).totalHours ?? 0) }} h
</TableCell>
<TableCell class="print:hidden"></TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
<aside id="timesheets"
class="fixed top-0 w-90 -ml-8 py-8 px-4 h-full overflow-y-auto border-r border-sidebar-border/30 print:hidden bg-sidebar/50">
<div class="flex w-full max-w-sm items-center space-x-2">
<Input placeholder="Suchen..." class="w-full sticky top-0 z-10 bg-background" />
<Button @click="createTimesheet">
<Plus /> Neu
</Button>
</div>
<ul class="flex flex-col mt-6">
<li v-for="timesheet in timesheets" @click="selectTimesheet(timesheet)"
:data-active="selectedTimesheet >= 0 && timesheets[selectedTimesheet].id === timesheet.id"
class="data-[active=true]:bg-background data-[active=true]:rounded data-[active=true]:shadow border-sidebar-border/75 data-[active=true]:border-transparent data-[active=true]:z-10 border-b p-4 hover:bg-accent flex gap-2 flex-col -my-px">
<h3 class="font-semibold">{{ timesheet.title }}</h3>
<div class="text-sm flex items-center justify-between gap-3">
<span v-if="timesheetSummary(timesheet).earliestDate" class="grow">
<span class="text-muted-foreground">{{
toShortLocalDate(timesheetSummary(timesheet).earliestDate!) }}</span>
<span class="text-muted-foreground"
v-if="timesheetSummary(timesheet).latestDate && timesheetSummary(timesheet).earliestDate && timesheetSummary(timesheet).latestDate! > timesheetSummary(timesheet).earliestDate!">
–
{{ toShortLocalDate(timesheetSummary(timesheet).latestDate!) }}</span>
</span>
<span v-if="timesheetSummary(timesheet).totalHours">{{ timesheetSummary(timesheet).totalHours }}
Std.</span>
<Progress
v-if="timesheetSummary(timesheet).hoursBilled && timesheetSummary(timesheet).hoursBilled > 0 && timesheetSummary(timesheet).totalHours"
:model-value="(timesheetSummary(timesheet).hoursBilled / timesheetSummary(timesheet).totalHours) * 100"
class="h-1 w-10 text-muted-foreground" />
</div>
</li>
</ul>
</aside>
</AppLayout>
</template>
+21 -26
View File
@@ -17,16 +17,16 @@ interface AuthConfigContent {
const authConfigContent = computed<AuthConfigContent>(() => {
if (showRecoveryInput.value) {
return {
title: 'Recovery Code',
description: 'Please confirm access to your account by entering one of your emergency recovery codes.',
toggleText: 'login using an authentication code',
title: 'Wiederherstellungscode',
description: 'Bitte bestätige den Zugriff auf Dein Konto, indem Du einen Deiner Notfall-Wiederherstellungscodes eingeben.',
toggleText: 'mit Authentifizierungscode anmelden',
};
}
return {
title: 'Authentication Code',
description: 'Enter the authentication code provided by your authenticator application.',
toggleText: 'login using a recovery code',
description: 'Bitte gib das Einmalkennwort aus Deiner Authentifizierungs-App ein.',
toggleText: 'mit einem Wiederherstellungscode anmelden',
};
});
@@ -44,51 +44,46 @@ const codeValue = computed<string>(() => code.value.join(''));
<template>
<AuthLayout :title="authConfigContent.title" :description="authConfigContent.description">
<Head title="Two-Factor Authentication" />
<div class="space-y-6">
<template v-if="!showRecoveryInput">
<Form v-bind="store.form()" class="space-y-4" reset-on-error @error="code = []" #default="{ errors, processing, clearErrors }">
<Form v-bind="store.form()" class="space-y-4" reset-on-error @error="code = []"
#default="{ errors, processing, clearErrors }">
<input type="hidden" name="code" :value="codeValue" />
<div class="flex flex-col items-center justify-center space-y-3 text-center">
<div class="flex w-full items-center justify-center">
<PinInput id="otp" placeholder="○" v-model="code" type="number" otp>
<PinInput id="otp" placeholder="" v-model="code" type="number" otp>
<PinInputGroup>
<PinInputSlot v-for="(id, index) in 6" :key="id" :index="index" :disabled="processing" autofocus />
<PinInputSlot v-for="(id, index) in 6" :key="id" :index="index"
:disabled="processing" autofocus class="bg-background" />
</PinInputGroup>
</PinInput>
</div>
<InputError :message="errors.code" />
</div>
<Button type="submit" class="w-full" :disabled="processing">Continue</Button>
<Button type="submit" variant="action" class="w-full" :disabled="processing">Weiter</Button>
<div class="text-center text-sm text-muted-foreground">
<span>or you can </span>
<button
type="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
<Button type="button" variant="link" @click="() => toggleRecoveryMode(clearErrors)">
{{ authConfigContent.toggleText }}
</button>
</Button>
</div>
</Form>
</template>
<template v-else>
<Form v-bind="store.form()" class="space-y-4" reset-on-error #default="{ errors, processing, clearErrors }">
<Input name="recovery_code" type="text" placeholder="Enter recovery code" :autofocus="showRecoveryInput" required />
<Form v-bind="store.form()" class="space-y-4" reset-on-error
#default="{ errors, processing, clearErrors }">
<Input name="recovery_code" type="text" placeholder="Enter recovery code"
:autofocus="showRecoveryInput" required />
<InputError :message="errors.recovery_code" />
<Button type="submit" class="w-full" :disabled="processing">Continue</Button>
<Button type="submit" class="w-full" :disabled="processing">Weiter</Button>
<div class="text-center text-sm text-muted-foreground">
<span>or you can </span>
<button
type="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
<Button type="button" variant="link" @click="() => toggleRecoveryMode(clearErrors)">
{{ authConfigContent.toggleText }}
</button>
</Button>
</div>
</Form>
</template>
+1 -1
View File
@@ -2,7 +2,7 @@
import HeadingSmall from '@/components/HeadingSmall.vue';
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
import { Badge } from '@/components/ui/badge';
import { Badge } from '@/components/ui/crm-badge';
import { Button } from '@/components/ui/crm-button';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import AppLayout from '@/layouts/AppLayout.vue';