Two month of work
This commit is contained in:
@@ -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>
|
||||
@@ -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 }} </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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user