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