Show invoice buttons depending of payment status. Fixes #54

This commit is contained in:
2025-11-14 11:55:41 +01:00
parent 9a84d36d68
commit 5cb0f97f8b
8 changed files with 356 additions and 241 deletions
+36 -12
View File
@@ -39,6 +39,9 @@ @theme inline {
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@@ -66,17 +69,17 @@ @theme inline {
--color-main: var(--main-background); --color-main: var(--main-background);
/* /* https://typescale.com/ /*
https://typescale.com/ /* Major Third */
Major Third --text-5xl: 3.815rem;
*/ --text-4xl: 3.052rem;
--text-xs: 0.64rem;
--text-sm: 0.8rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.563rem;
--text-2xl: 1.953rem;
--text-3xl: 2.441rem; --text-3xl: 2.441rem;
--text-2xl: 1.953rem;
--text-xl: 1.563rem;
--text-lg: 1.25rem;
--text-base: 1rem;
--text-sm: 0.8rem;
--text-xs: 0.64rem;
--shadow-arrow: 0 2px 1px rgb(0 0 0 / 0.1) --shadow-arrow: 0 2px 1px rgb(0 0 0 / 0.1)
} }
@@ -108,6 +111,7 @@ @layer utilities {
--font-sans: system-ui, sans-serif; --font-sans: system-ui, sans-serif;
font-size: 18px; font-size: 18px;
background-color: var(--sidebar-background); background-color: var(--sidebar-background);
/* background: linear-gradient(45deg, var(--color-slate-100), var(--color-orange-100)); */
letter-spacing: 0.006em; letter-spacing: 0.006em;
} }
} }
@@ -130,6 +134,8 @@ :root {
--accent-foreground: hsl(0 0% 9%); --accent-foreground: hsl(0 0% 9%);
--destructive: var(--color-red-500); --destructive: var(--color-red-500);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--success: var(--color-lime-400);
--success-foreground: var(--color-foreground);
--border: hsl(0 0% 92.8%); --border: hsl(0 0% 92.8%);
--input: var(--color-zinc-100); --input: var(--color-zinc-100);
--ring: hsl(0 0% 3.9%); --ring: hsl(0 0% 3.9%);
@@ -154,7 +160,8 @@ :root {
--status-paid: var(--color-lime-500); --status-paid: var(--color-lime-500);
--status-due: var(--color-amber-300); --status-due: var(--color-amber-300);
--status-reminded: var(--color-destructive); --status-reminded: var(--color-destructive);
--scrollbar-thumb: --alpha(black / 20%);
--scrollbar-track: transparent;
} }
.dark { .dark {
@@ -171,10 +178,12 @@ .dark {
--secondary-foreground: hsl(0 0% 98%); --secondary-foreground: hsl(0 0% 98%);
--muted: var(--color-neutral-700); --muted: var(--color-neutral-700);
--muted-foreground: var(--color-neutral-400); --muted-foreground: var(--color-neutral-400);
--accent: var(--color-neutral-900); --accent: oklch(25% 0 0);
--accent-foreground: hsl(0 0% 98%); --accent-foreground: hsl(0 0% 98%);
--destructive: var(--color-red-600); --destructive: var(--color-red-600);
--destructive-foreground: var(--color-red-200); --destructive-foreground: var(--color-red-200);
--success: var(--color-lime-900);
--success-foreground: var(--color-lime-400);
--border: var(--color-neutral-700); --border: var(--color-neutral-700);
--input: var(--color-neutral-700); --input: var(--color-neutral-700);
--ring: var(--color-neutral-500); --ring: var(--color-neutral-500);
@@ -198,11 +207,13 @@ .dark {
--status-paid: var(--color-lime-700); --status-paid: var(--color-lime-700);
--status-due: var(--color-amber-900); --status-due: var(--color-amber-900);
--status-reminded: var(--color-destructive-foreground); --status-reminded: var(--color-destructive-foreground);
--scrollbar-thumb: --alpha(white / 35%);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
} }
body { body {
@@ -222,4 +233,17 @@ @layer components {
.is-mac .visible-mac { .is-mac .visible-mac {
display: inherit !important; display: inherit !important;
} }
}
@layer utilities {
/* Remove default dialog close button */
[data-slot=dialog-content] button.ring-offset-background {
display: none;
}
/* Backdrop */
[data-slot=dialog-overlay] {
backdrop-filter: blur(var(--blur-sm));
}
} }
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { X } from "lucide-vue-next"
</script>
<template>
<Button size="sm">
<X stroke-width="1.5" />
</Button>
</template>
<style scoped>
/* Win: top right, ghost */
button {
position: absolute;
top: 0;
right: 0;
height: 2rem;
width: 2rem;
border: none;
background-color: transparent;
color: var(--color-muted-foreground);
transition-property: background-color;
box-shadow: none;
}
button:hover {
background-color: var(--color-gray-200) !important;
}
button:active {
background-color: var(--color-neutral-300) !important;
}
.dark {
button:hover {
background-color: var(--color-neutral-900) !important;
}
button:active {
background-color: var(--color-neutral-950) !important;
}
}
/* Mac: top left, round bordered */
.is-mac {
button {
top: -0.75rem;
left: -0.75rem;
width: 1.5rem;
height: unset;
aspect-ratio: 1/1 !important;
border-radius: 100%;
border: 1px solid var(--color-border);
background-color: var(--color-muted);
box-shadow: var(--shadow-md);
svg {
width: var(--text-sm);
stroke-width: 2px;
}
}
}
.dark .is-mac {
button {
background-color: var(--color-neutral-800);
}
}
/* Linux: inline, round borderless */
.is-linux {
button {
position: unset;
background-color: var(--color-neutral-700);
border-radius: 100%;
height: 1.563rem;
width: 1.563rem;
}
}
</style>
@@ -120,7 +120,7 @@ const calcTaxes = (amount: number) => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody class="overflow-clip rounded-lg"> <TableBody class="overflow-clip rounded-lg shadow">
<TableRow v-for="invoice in invoices" :key="invoice.nr" @click="onItemClicked(invoice)" <TableRow v-for="invoice in invoices" :key="invoice.nr" @click="onItemClicked(invoice)"
class="select-none md:select-auto cursor-default bg-background" class="select-none md:select-auto cursor-default bg-background"
:class="statusTextStyle(invoice.paymentStatus)"> :class="statusTextStyle(invoice.paymentStatus)">
@@ -217,8 +217,8 @@ const calcTaxes = (amount: number) => {
} }
.document-table td { .document-table td {
padding-top: 1em !important; padding-top: 1.125em !important;
padding-bottom: 1em !important; padding-bottom: 1.125em !important;
} }
.document-table tfoot tr:first-child td { .document-table tfoot tr:first-child td {
@@ -24,15 +24,14 @@ import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { StatusBadge, statusBadgeLabels, statusTextStyle, StatusBadgeVariants } from '@/components/ui/status-badge' import { StatusBadge, statusBadgeLabels, statusTextStyle, StatusBadgeVariants } from '@/components/ui/status-badge'
import LineItemTable from '@/components/documents/LineItemTable.vue' import LineItemTable from '@/components/documents/LineItemTable.vue'
import { Eye, FileText, CircleEllipsis, Trash, Trash2, BookUser, User, CodeXml, CalendarIcon, MessageCircleQuestion, X, CircleX, Logs, ListCheck, ClipboardCheck, ClipboardList, Loader, Loader2 } from "lucide-vue-next" import { Eye, FileText, Trash2, BookUser, User, CodeXml, CalendarIcon, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, FileX, Ban } from "lucide-vue-next"
import { alertStore } from "@/stores/alertStore" import { alertStore } from "@/stores/alertStore"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { exportPdf, exportXml } from "@/routes/invoice"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'
import { GrowingTextarea } from '../ui/growing-textarea' import { GrowingTextarea } from '../ui/growing-textarea'
import { toast } from "vue-sonner" import { toast } from "vue-sonner"
import { SendMailDialog } from "../ui/send-mail-dialog" import { SendMailDialog } from "../ui/send-mail-dialog"
import { Kbd, KbdGroup } from "../ui/kbd"
import DialogClose from "../ui/dialog/DialogClose.vue"
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue"
const props = defineProps<{ const props = defineProps<{
invoiceData: Invoice | null, invoiceData: Invoice | null,
@@ -182,6 +181,14 @@ watch(importContact,
{ deep: true } { deep: true }
) )
const billingContactEmail = computed<string | undefined>(() => {
// TODO: use e-mail from billing data if set
// and fallback to primary contact email
if (invoice.value?.customer && invoice.value?.customer.contacts[0])
return invoice.value?.customer?.contacts[0].email
else return ""
})
onUpdated(() => { onUpdated(() => {
isLoading.value = false; isLoading.value = false;
// console.log("onUpdated", "Dirty: " + isDirty.value, "loading: " + isLoading.value) // console.log("onUpdated", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
@@ -190,7 +197,7 @@ onUpdated(() => {
const saveChanges = () => { const saveChanges = () => {
if (invoice.value) { if (invoice.value) {
emit('save', invoice.value) emit('save', invoice.value)
isOpen.value = false // isOpen.value = false
} }
} }
@@ -201,7 +208,6 @@ const cancelChanges = (event: Event | null) => {
"Es gibt ungespeicherte Änderungen, die dann verloren gehen.", "Es gibt ungespeicherte Änderungen, die dann verloren gehen.",
{ {
actionText: "Änderungen verwerfen", actionText: "Änderungen verwerfen",
actionVariant: "destructive",
onAction: () => { onAction: () => {
emit('cancel') emit('cancel')
isOpen.value = false isOpen.value = false
@@ -234,6 +240,11 @@ const downloadXml = function () {
window?.open('/invoice/' + invoice.value.id + '/xml'); window?.open('/invoice/' + invoice.value.id + '/xml');
} }
const issueInvoice = function () {
if (!invoice.value) return;
invoice.value.paymentStatus = 'issued'
}
const deleteInvoice = function () { const deleteInvoice = function () {
alert.show( alert.show(
"Möchtest Du diese Rechnung wirklich löschen?", "Möchtest Du diese Rechnung wirklich löschen?",
@@ -249,34 +260,14 @@ const deleteInvoice = function () {
) )
} }
const cancelInvoice = function () {
if (!invoice.value) return;
invoice.value.paymentStatus = 'cancelled'
}
const openReminderDialog = function () { const openReminderDialog = function () {
if (!invoice.value) return if (!invoice.value) return
reminderDialogOpen.value = true reminderDialogOpen.value = true
// alert.show(
// "Zahlungserinnerung senden?",
// "E-mail an " + invoice.value.customer?.contacts[0].email,
// {
// actionText: "Senden",
// onAction: async () => {
// // make button spin and disable button
// reminderLoading.value = true
// // await axios call
// await axios.get('/api/invoices/' + invoice.value.id + '/remind')
// .then(function (response) {
// toast.success("Zahlungserinnerung gesendet", { description: "daniel@vollstock.de" })
// })
// .catch(function (error) {
// toast.error(error.title, { description: error.message })
// })
// .finally(() => {
// reminderLoading.value = false
// })
// }
// }
// )
} }
const sendReminder = async function (recipient: string | undefined) { const sendReminder = async function (recipient: string | undefined) {
@@ -314,141 +305,210 @@ const updateTotalAmount = () => {
<template> <template>
<Dialog id="invoice-dialog" v-model:open="isOpen"> <Dialog id="invoice-dialog" v-model:open="isOpen">
<DialogContent <DialogContent
class="sm:max-w-[min((100%-2rem),1152px)] grid-rows-[auto_minmax(0,1fr)_auto] p-0 h-[calc(100dvh-2rem)]" class="sm:max-w-[min((100%-2rem),1152px)] grid-rows-[auto_minmax(0,1fr)_auto] h-[calc(100dvh-2rem)] gap-0 p-0 outline-none"
@escapeKeyDown="cancelChanges" @interactOutside="cancelChanges"> @escapeKeyDown="cancelChanges" @interactOutside="cancelChanges">
<DialogHeader class="px-3 pt-3 flex flex-row justify-end"> <DialogHeader class="p-4 md:p-6 lg:p-12 pb-0 md:pb-2 lg:pb-8 flex flex-row items-start gap-6">
<DialogTitle class="sr-only">Rechnung {{ invoice?.nr }}</DialogTitle>
<DialogDescription>
{{ invoice?.title }}
</DialogDescription>
<div v-if="invoice && invoice.id > 0" class="hidden md:flex mr-4">
<Button :size="'sm'" :variant="'ghost'" @click="preview">
<Eye :strokeWidth="1.5" class="text-current" />
<span>Vorschau</span>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button :size="'sm'" :variant="'ghost'" @click="downloadPdf">
<FileText :strokeWidth="1.5" class="text-current" />
<span>PDF</span>
</Button>
</TooltipTrigger>
<TooltipContent>
ZUGFeRD
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button :size="'sm'" :variant="'ghost'" @click="downloadXml">
<CodeXml :strokeWidth="1.5" class="text-current" />
<span>XML</span>
</Button>
</TooltipTrigger>
<TooltipContent>
XRechnung
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Sheet as-child class="relativ">
<SheetTrigger>
<Button :size="'sm'" :variant="'ghost'">
<ClipboardList :strokeWidth="1.5" class="text-current" />
<span>Audit</span>
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Are you absolutely sure?</SheetTitle>
<SheetDescription>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
<Button :size="'sm'" :variant="'ghost'" @click="deleteInvoice"
class="text-destructive hover:bg-destructive/5 hover:text-destructive">
<Trash2 :strokeWidth="1.5" class="text-current" />
<span>Löschen</span>
</Button>
<div class="flex flex-col grow">
<DialogTitle class="text-primary-foreground font-bold text-left z-1">
<h1 v-if="invoice.id > 0">Rechnung {{ invoice.nr }}</h1>
<h1 v-else>Neue Rechnung</h1>
</DialogTitle>
<DialogDescription>
<Input
class="text-foreground md:text-base text-ellipsis px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
type="text" v-model="invoice.title" placeholder="Titel" />
</DialogDescription>
</div> </div>
<div class="md:hidden">
<DropdownMenu> <div class="flex gap-2 items-center">
<DropdownMenuTrigger> <TooltipProvider>
<Button :variant="'ghost'" :size="'lg'"> <!-- Save -->
<CircleEllipsis class="size-6" /> <Button v-if="invoice && invoice.id > 0 && isDirty" class="grow md:grow-0" size="sm"
</Button> @click="saveChanges">
</DropdownMenuTrigger> <Check stroke-width="1.5" />
<DropdownMenuContent class="mr-8"> Speichern
<DropdownMenuItem class="flex justify-between" @click="preview"> </Button>
<span class="mr-2">Vorschau</span>
<Eye :strokeWidth="1.5" class="text-current" /> <!-- Issue -->
</DropdownMenuItem> <Tooltip v-if="invoice && invoice.paymentStatus == 'draft'">
<DropdownMenuSeparator /> <TooltipTrigger>
<DropdownMenuItem class="flex justify-between" @click="downloadPdf"> <Button size="sm" variant="action" @click="issueInvoice">
<div class="mr-2 flex flex-col"> Rechnung stellen
<span>PDF speichern</span> </Button>
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span> </TooltipTrigger>
</div> <TooltipContent>
<FileText :strokeWidth="1.5" class="text-current" /> Bearbeitung sperren und Rechnung erstellen
</DropdownMenuItem> </TooltipContent>
<DropdownMenuItem class="flex justify-between" @click="downloadXml"> </Tooltip>
<div class="mr-2 flex flex-col">
<span>XML speichern</span>
<span class="text-xs text-muted-foreground">(XRechnung)</span> <!-- Paid -->
</div> <Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
<CodeXml :strokeWidth="1.5" class="text-current" /> <TooltipTrigger>
</DropdownMenuItem> <Button size="sm" variant="success" @click="invoice.paymentStatus = 'paid'">
<DropdownMenuSeparator /> <FileCheck stroke-width="1.5" /> Bezahlt
<DropdownMenuItem class="flex justify-between text-destructive"> </Button>
<span class="mr-2">Löschen</span> </TooltipTrigger>
<Trash :strokeWidth="1.5" class="text-current" /> <TooltipContent>
</DropdownMenuItem> Als bezahlt markieren
</DropdownMenuContent> </TooltipContent>
</DropdownMenu> </Tooltip>
<!-- Remind -->
<Tooltip v-if="invoice && ['due', 'reminded'].includes(invoice.paymentStatus)">
<TooltipTrigger>
<Button size="sm" variant="destructive" @click="openReminderDialog"
:disabled="reminderLoading" class="gap-0">
<Loader2 class="h-4 w-4 transition-[width] ease-in-out animate-spin"
stroke-width="1.5"
:class="{ 'w-0!': !reminderLoading, 'mr-2': reminderLoading }" />
Erinnern
</Button>
</TooltipTrigger>
<TooltipContent>
Zahlungserinnerung per E-Mail senden
</TooltipContent>
</Tooltip>
<!-- Ellipsis menu -->
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
<Ellipsis class="size-4" stroke-width="1.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<!-- Preview -->
<DropdownMenuItem v-if="invoice?.paymentStatus == 'draft'"
class="flex items-center justify-between" @click="preview">
<div class="flex items-center gap-4">
<Eye :strokeWidth="1.5" class="text-current" />
<span class="mr-4">Vorschau</span>
</div>
<KbdGroup>
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>P</Kbd>
</KbdGroup>
</DropdownMenuItem>
<!-- PDF -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'" class="flex justify-between"
@click="downloadPdf">
<div class="flex items-center gap-4">
<FileText stroke-width="1.5" class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>PDF exportieren</span>
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
</div>
</div>
<KbdGroup>
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>E</Kbd>
</KbdGroup>
</DropdownMenuItem>
<!-- XML -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'" class="flex justify-between"
@click="downloadXml">
<div class="flex items-center gap-4">
<CodeXml stroke-width="1.5" class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>XML exportieren</span>
<span class="text-xs text-muted-foreground">(XRechnung)</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Cancel -->
<DropdownMenuItem
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
class="flex justify-between" @click="deleteInvoice">
<div class="flex items-center gap-2">
<!-- <FileX stroke-width="1.5" class="text-muted-foreground"/> -->
<Ban :strokeWidth="1.5" class="text-current" />
<span class="mr-2">Stornieren</span>
</div>
</DropdownMenuItem>
<!-- Delete -->
<DropdownMenuItem
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
@click="deleteInvoice">
<div class="flex items-center gap-2">
<Trash2 :strokeWidth="1.5" class="text-current" />
<span class="mr-2">Löschen</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
<DialogClose as-child>
<DialogCloseButton />
</DialogClose>
</div> </div>
</DialogHeader> </DialogHeader>
<div class="overflow-y-auto px-6" v-if="invoice"> <div class="overflow-y-auto p-4 md:p-6 lg:p-12 pt-0!" v-if="invoice">
<div id="document"> <div id="document">
<div id="document-header" <div id="document-header"
class="sticky top-0 py-4 bg-white dark:bg-neutral-800 z-1 flex items-end gap-12"> class="h-7 mb-12 sticky top-0 bg-background z-1 flex flex-col md:flex-row justify-between items-center">
<div class="grow">
<h1 class="text-xl text-primary-foreground font-bold" v-if="invoice.id > 0"> <!-- Status -->
Rechnung {{ invoice.nr <div>
}}</h1> <StatusBadge size="lg" :variant="invoice.paymentStatus">{{
<h1 class="text-xl text-primary-foreground font-bold" v-else>Neue statusBadgeLabels[invoice.paymentStatus] }}
Rechnung </StatusBadge>
</h1>
<Input
class="md:text-xl px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
type="text" v-model="invoice.title" placeholder="Titel" />
</div> </div>
<div class="flex flex-col m-0 items-end gap-0">
<span class="text-md text-muted-foreground">{{ toCurrency(invoice.totalAmount) }}</span> <!-- <div class="flex gap-4 mr-6 w-33" v-if="invoice && invoice.paymentStatus == 'draft'">
<span class="text-2xl font-bold">{{ toCurrency(toFixedRounded(Number(invoice.totalAmount * </div>
1.19), 2)) }}</span>
<Select v-model="invoice.paymentStatus" v-else>
<SelectTrigger class="bg-transparent! shadow-none! outline-0 border-0 pr-8 w-41 pl-0">
<StatusBadge size="lg" :variant="invoice.paymentStatus">{{
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
<!-- <SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(label, value) in statusBadgeLabels" :value="value">
<SelectLabel>{{ label }}</SelectLabel>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select> -->
<!-- Betrag -->
<div class="grid grid-cols-[auto_auto_auto_auto] items-end gap-x-6 gap-y-0">
<label class="text-muted-foreground text-xs pb-[0.4rem]">Netto</label>
<span class="text-lg text-muted-foreground place-self-end">{{
toCurrency(invoice.totalAmount) }}</span>
<label class="text-muted-foreground text-xs pb-[0.4rem]">Brutto</label>
<span class="text-xl font-bold place-self-end">{{
toCurrency(toFixedRounded(Number(invoice.totalAmount *
1.19), 2)) }}</span>
</div> </div>
</div> </div>
<div id="document-meta" <div id="document-meta"
class="flex-none md:flex gap-12 mt-6 2 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg"> class="flex-none md:flex gap-12 2 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex flex-row gap-4"> <div class="flex">
<Input type="text" v-model="invoice.billingData.companyName" placeholder="Firma" <Input type="text" v-model="invoice.billingData.companyName" placeholder="Firma"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" /> class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
@@ -518,38 +578,6 @@ const updateTotalAmount = () => {
<Table> <Table>
<TableBody> <TableBody>
<!-- Status -->
<TableRow>
<TableHead class="w-1">Status</TableHead>
<TableCell class="flex items-center gap-4">
<StatusBadge size="sm" :variant="invoice.paymentStatus">{{
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
<Select v-model="invoice.paymentStatus">
<SelectTrigger
class="w-full bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-0 h-8!">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(label, value) in statusBadgeLabels"
:value="value">
<SelectLabel>{{ label }}</SelectLabel>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button v-if="['due', 'reminded'].includes(invoice.paymentStatus)"
:size="'sm'" :variant="'destructive'" @click="openReminderDialog"
:disabled="reminderLoading">
<Loader2 class="h-4 w-4 transition-[width] ease-in-out animate-spin"
:class="{ 'w-0!': !reminderLoading }" />
Mahnen
</Button>
</TableCell>
</TableRow>
<!-- Rechnungsdatum --> <!-- Rechnungsdatum -->
<TableRow> <TableRow>
<TableHead>Datum</TableHead> <TableHead>Datum</TableHead>
@@ -624,48 +652,24 @@ const updateTotalAmount = () => {
</div> </div>
</div> </div>
<div id="document-text" class="px-4 mt-6"> <div id="document-text" class="mt-6 md:mt-8 lg:mt-12">
<GrowingTextarea v-model="invoice.text" placeholder="Anschreiben" <GrowingTextarea v-model="invoice.text" placeholder="Anschreiben"
class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" /> class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" />
</div> </div>
<LineItemTable :lineItems="invoice.items" @update:lineItems="updateTotalAmount" /> <LineItemTable :lineItems="invoice.items" @update:lineItems="updateTotalAmount" sticky-top="7"
class="mt-4" />
</div> </div>
</div> </div>
<DialogFooter class="p-6 pt-0 flex-row">
<div :size="'sm'" class="hidden md:block flex-grow-4"></div>
<Button class="grow md:grow-0" @click="cancelChanges">
Abbrechen
</Button>
<Button class="grow md:grow-0" :variant="'action'" @click="saveChanges"
:disabled="!isDirty">Speichern</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<SendMailDialog v-model:open="reminderDialogOpen" title="Zahlungserinnerung senden?" description="" <SendMailDialog v-model:open="reminderDialogOpen" title="Zahlungserinnerung senden?" description=""
:recipient="invoice?.customer?.contacts[0].email" @send="(recipient) => sendReminder(recipient)" /> :recipient="billingContactEmail" @send="(recipient) => sendReminder(recipient)" />
</template> </template>
<style> <style></style>
/* Remove close X */
[data-slot=dialog-content] button.ring-offset-background {
/* display: none; */
border-radius: 100%;
position: absolute;
left: 1rem;
width: 1rem;
height: 1rem;
color: var(--color-destructive);
}
/* Backdrop */
[data-slot=dialog-overlay] {
backdrop-filter: blur(var(--blur-sm));
}
</style>
@@ -1,5 +1,5 @@
<!-- TODO: Mengenfeld Komma als decimal point --> <!-- TODO: Mengenfeld Komma als decimal point -->
<!-- Enter in LineItem = neue Zeile --> <!-- TODO: Enter in LineItem = neue Zeile -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, HTMLAttributes } from 'vue' import { ref, watch, HTMLAttributes } from 'vue'
@@ -20,6 +20,7 @@ import { GrowingTextarea } from '../ui/growing-textarea';
const props = defineProps<{ const props = defineProps<{
lineItems: LineItem[], lineItems: LineItem[],
stickyTop: number | string,
class?: HTMLAttributes['class'] class?: HTMLAttributes['class']
}>() }>()
@@ -60,7 +61,8 @@ const recalculatePositions = () => {
<div :class="cn(props.class)"> <div :class="cn(props.class)">
<div class="backdrop-blur mt-8 bg-background/80 sticky top-[100px] z-1"> <div class="backdrop-blur-sm bg-background/90 sticky z-1 pt-8"
:style="'top: calc(var(--spacing) * ' + (props.stickyTop ? props.stickyTop : 0) + ');'">
<Table class="table-fixed"> <Table class="table-fixed">
<TableHeader> <TableHeader>
<TableRow class="hover:bg-transparent dark:hover:bg-transparent border-b-1"> <TableRow class="hover:bg-transparent dark:hover:bg-transparent border-b-1">
@@ -71,7 +73,7 @@ const recalculatePositions = () => {
<TableHead class="w-20 text-center">Menge</TableHead> <TableHead class="w-20 text-center">Menge</TableHead>
<TableHead class="w-1/8 text-right pr-5">Einzel</TableHead> <TableHead class="w-1/8 text-right pr-5">Einzel</TableHead>
<TableHead class="w-1/8 text-right">Total</TableHead> <TableHead class="w-1/8 text-right">Total</TableHead>
<TableHead class="w-16"></TableHead> <TableHead class="w-8"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
</Table> </Table>
@@ -94,9 +96,9 @@ const recalculatePositions = () => {
<!-- Posten --> <!-- Posten -->
<TableCell> <TableCell>
<Input v-model="element.title" placeholder="Posten" <Input v-model="element.title" placeholder="Posten"
class="font-bold h-fit p-1 h-7! m-0 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none hover:border-1 dark:hover:border-1 placeholder:text-muted-foreground/30 shadow-none mb-1" /> class="font-bold p-1 h-7! m-0 -mb-1 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none hover:border-1 dark:hover:border-1 placeholder:text-muted-foreground/30 shadow-none" />
<GrowingTextarea v-model="element.description" placeholder="Beschreibung" <GrowingTextarea v-model="element.description" placeholder="Beschreibung"
class="font-light m-0 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 py-0 px-1 m-0 border-none shadow-none" /> class="text-muted-foreground font-light m-0 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 py-0 px-1 m-0 border-none shadow-none" />
</TableCell> </TableCell>
@@ -139,18 +141,18 @@ const recalculatePositions = () => {
<!-- Total --> <!-- Total -->
<TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity) <TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity)
}} }}
</TableCell> </TableCell>
<!-- Buttons --> <!-- Buttons -->
<TableCell class="w-16 text-right"> <TableCell class="w-8 text-right">
<Button :variant="'ghost'" :size="'sm'" @click="deleteItem(element)" <Button variant="ghost" size="sm" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive"> class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 :size="18" /> <Trash2 :size="18" />
</Button> </Button>
<Button :variant="'ghost'" :size="'sm'" @click="" class="has-[>svg]:px-1 text-muted-foreground"> <!-- <Button :variant="'ghost'" :size="'sm'" @click="" class="has-[>svg]:px-1 text-muted-foreground">
<CirclePlus /> <CirclePlus />
</Button> </Button> -->
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -161,7 +163,7 @@ const recalculatePositions = () => {
<TableRow class="hover:bg-transparent dark:hover:bg-transparent"> <TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8" class="text-center"> <TableCell colspan="8" class="text-center">
<Button class="mt-2" variant="ghost" @click="newItem"> <Button class="mt-4" variant="ghost" @click="newItem">
<Plus /> Neue Zeile <Plus /> Neue Zeile
</Button> </Button>
+3 -1
View File
@@ -12,7 +12,9 @@ export const buttonVariants = cva(
action: action:
'bg-blue-600 border-b-1 border-t-1 border-t-blue-400 border-b-blue-800 active:bg-blue-700 hover:bg-blue-500 text-white', 'bg-blue-600 border-b-1 border-t-1 border-t-blue-400 border-b-blue-800 active:bg-blue-700 hover:bg-blue-500 text-white',
destructive: destructive:
'bg-destructive dark:bg-red-700 text-white border-b-1 border-t-1 border-t-red-200 border-b-red-700 dark:border-t-red-400 dark:border-b-red-800 hover:bg-red-600 hover:bg-red-600 active:bg-red-500 active:inset-shadow-red-950 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40', 'bg-destructive dark:bg-red-700 text-white border-b-1 border-t-1 border-t-red-200 border-b-red-700 dark:border-t-red-400 dark:border-b-red-800 hover:bg-red-600 active:bg-red-500 active:inset-shadow-red-950 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
success:
'bg-success text-success-foreground border-b-1 border-t-1 border-t-lime-200 border-b-lime-500 dark:border-t-lime-400 dark:border-b-lime-800 hover:bg-lime-500 active:bg-lime-500 active:inset-shadow-lime-600 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
outline: outline:
'border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 'border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: secondary:
@@ -26,7 +26,7 @@ watch(() => props.open,
</script> </script>
<template> <template>
<Dialog v-bind="forwarded" :open="open"> <Dialog v-bind="forwarded" :open="open" data-slot="send-mail-dialog">
<DialogContent class="sm:max-w-[425px]"> <DialogContent class="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{{ props.title }}</DialogTitle> <DialogTitle>{{ props.title }}</DialogTitle>
@@ -57,9 +57,9 @@ watch(() => props.open,
</Dialog> </Dialog>
</template> </template>
<style> <style scoped>
/* Remove close X */ /* Remove close X */
[data-slot=dialog-content] button.ring-offset-background { [data-slot=send-mail-dialog] [data-slot=dialog-content] button.ring-offset-background {
display: none; display: none;
} }
</style> </style>
@@ -13,7 +13,7 @@ export const statusBadgeVariants = cva(
issued: issued:
"bg-transparent border-sky-200 text-sky-600 dark:bg-sky-800 dark:text-sky-300 dark:border-0", "bg-transparent border-sky-200 text-sky-600 dark:bg-sky-800 dark:text-sky-300 dark:border-0",
paid: paid:
"border-none bg-lime-400 dark:bg-lime-900 dark:text-lime-400", "border-none bg-success text-success-foreground",
due: due:
"font-bold border-none bg-amber-300 text-amber-800 dark:bg-amber-900 dark:text-amber-500", "font-bold border-none bg-amber-300 text-amber-800 dark:bg-amber-900 dark:text-amber-500",
reminded: reminded:
@@ -25,7 +25,7 @@ export const statusBadgeVariants = cva(
size: { size: {
default: '', default: '',
sm: 'lg:aspect-1/1 lg:p-1, lg:rounded-full lg:w-auto lg:w-1 text-transparent dark:text-transparent', sm: 'lg:aspect-1/1 lg:p-1, lg:rounded-full lg:w-auto lg:w-1 text-transparent dark:text-transparent',
lg: '', lg: 'text-sm px-6!',
icon: '', icon: '',
}, },
}, },