Files
Caramel-CRM/resources/js/pages/Timesheets.vue
T

353 lines
16 KiB
Vue

<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, 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.customerId = null
invoice.billingData = null
timesheets.value[selectedTimesheet.value].entries?.forEach(((entry, i) => {
if (entry.billed) return
let lineItem: LineItem = newLineItem(false)
lineItem.position = i + 1
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>
<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>