Two month of work

This commit is contained in:
2026-02-17 10:35:03 +01:00
parent 0ffbeeedff
commit d9fd3d1ccb
158 changed files with 5637 additions and 1512 deletions
-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';