Add InvoiceService and initial invoice dashboard widget
This commit is contained in:
@@ -185,6 +185,19 @@ public function salesStatistics()
|
|||||||
return ApiDataTransformer::snakeToCamel($statistics);
|
return ApiDataTransformer::snakeToCamel($statistics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getDueInvoices()
|
||||||
|
{
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$invoices = Invoice::where('due_date', '<=', $today)
|
||||||
|
->whereIn('payment_status', ['issued', 'due', 'reminded'])
|
||||||
|
->orderBy('due_date', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $invoices->map(function ($invoice) {
|
||||||
|
return ApiDataTransformer::snakeToCamel($invoice->toArray());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function preview($id)
|
public function preview($id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ const updateLineItems = (newItems: LineItem[]) => {
|
|||||||
|
|
||||||
// Calculate the new total amount
|
// Calculate the new total amount
|
||||||
let total = 0;
|
let total = 0;
|
||||||
updatedItems.forEach(item => {
|
updatedItems.forEach((item: LineItem) => {
|
||||||
total += item.quantity * item.price;
|
total += item.quantity * item.price;
|
||||||
});
|
});
|
||||||
invoice.value.totalAmount = total;
|
invoice.value.totalAmount = total;
|
||||||
|
|||||||
@@ -2,31 +2,38 @@
|
|||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import { onMounted, ref } from "vue"
|
import { onMounted, ref } from "vue"
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { Trophy, UserCheck2, X, ChevronRight } from 'lucide-vue-next';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'
|
||||||
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 { invoices } from '@/routes';
|
||||||
import { toRoundedCurrency } from '@/lib/utils'
|
import { toRoundedCurrency, toLocalDate } from '@/lib/utils'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { usePage, router } from '@inertiajs/vue3';
|
||||||
import axios, { AxiosError } from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import { AppPageProps, Todo } from "@/types";
|
import { SalesStatistics, Todo, Invoice } from "@/types";
|
||||||
import Todos from '@/components/Todos.vue';
|
import Todos from '@/components/Todos.vue';
|
||||||
|
import InvoiceService from '@/services/InvoiceService';
|
||||||
|
import { Table, TableRow, TableCell } from '@/components/ui/crm-table';
|
||||||
|
|
||||||
const salesStatistics = ref({
|
interface Props {
|
||||||
|
dueInvoices: Invoice[];
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const salesStatistics = ref<SalesStatistics>({
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
totalRevenue: 0,
|
totalRevenue: 0,
|
||||||
paid: 0,
|
paid: 0,
|
||||||
|
paidPercent: 0,
|
||||||
draft: 0,
|
draft: 0,
|
||||||
|
draftPercent: 0,
|
||||||
issued: 0,
|
issued: 0,
|
||||||
|
issuedPercent: 0,
|
||||||
due: 0,
|
due: 0,
|
||||||
|
duePercent: 0,
|
||||||
reminded: 0,
|
reminded: 0,
|
||||||
|
remindedPercent: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const salesTarget = ref(60000) // TODO: aus settings
|
const salesTarget = ref(60000) // TODO: aus settings
|
||||||
const todos = ref<Todo[]>([])
|
const todos = ref<Todo[]>([])
|
||||||
const showCompleted = ref(false)
|
const showCompleted = ref(false)
|
||||||
@@ -48,8 +55,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Load sales statistics
|
// Load sales statistics
|
||||||
try {
|
try {
|
||||||
let response = await axios.get('/api/invoices/salesStatistics')
|
salesStatistics.value = await InvoiceService.getSalesStatistics()
|
||||||
salesStatistics.value = response.data
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Fehler beim Laden der Daten', { description: (error as AxiosError).message })
|
toast.error('Fehler beim Laden der Daten', { description: (error as AxiosError).message })
|
||||||
}
|
}
|
||||||
@@ -67,7 +73,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
|
|
||||||
<!-- Nachrichten -->
|
<!-- Nachrichten -->
|
||||||
<Card class="break-inside-avoid mb-12">
|
<!-- <Card class="break-inside-avoid mb-12">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Benachrichtigungen</CardTitle>
|
<CardTitle>Benachrichtigungen</CardTitle>
|
||||||
<CardDescription>Card Description</CardDescription>
|
<CardDescription>Card Description</CardDescription>
|
||||||
@@ -104,11 +110,11 @@ onMounted(async () => {
|
|||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button>Alles leeren</Button>
|
<Button>Alles leeren</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card> -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Aufgaben -->
|
<!-- Aufgaben -->
|
||||||
<Card class="break-inside-avoid mb-8">
|
<Card class="break-inside-avoid mb-8 ">
|
||||||
<CardHeader class="flex justify-between flex-wrap">
|
<CardHeader class="flex justify-between flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle class="mb-1.5">Aufgaben</CardTitle>
|
<CardTitle class="mb-1.5">Aufgaben</CardTitle>
|
||||||
@@ -120,7 +126,7 @@ onMounted(async () => {
|
|||||||
<Label for="show-completed" class="text-muted-foreground">Erledigte</Label>
|
<Label for="show-completed" class="text-muted-foreground">Erledigte</Label>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent class="max-h-[600px] overflow-y-auto">
|
||||||
<Todos :modelValue="todos" :show-completed="showCompleted" :show-todoable="true" />
|
<Todos :modelValue="todos" :show-completed="showCompleted" :show-todoable="true" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -132,11 +138,21 @@ onMounted(async () => {
|
|||||||
<CardDescription>Card Description</CardDescription>
|
<CardDescription>Card Description</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<Table class="text-sm">
|
||||||
|
<TableRow v-for="invoice in props.dueInvoices" @mouseover="router.prefetch(invoices())" @click="router.visit(invoices())">
|
||||||
|
<TableCell class="align-top">
|
||||||
|
<span class="whitespace-nowrap" v-if="invoice.dueDate">{{ toLocalDate(invoice.dueDate)
|
||||||
|
}}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span class="font-semibold">{{ invoice.nr }}</span> {{ invoice.title }}<br>
|
||||||
|
<span class="text-muted-foreground">{{ invoice.billingData?.companyName }}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="align-top text-right">{{ toRoundedCurrency(invoice.totalAmount) }}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
|
||||||
<Link :href="invoices()" prefetch><Button>Zu den Rechnungen</Button></Link>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { type Invoice } from '@/types'
|
import { type Invoice } from '@/types'
|
||||||
import { newInvoice } from '@/types/index.d'
|
import { newInvoice } from '@/types/index.d'
|
||||||
import axios from 'axios'
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue'
|
import AppLayout from '@/layouts/AppLayout.vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import { Button } from '@/components/ui/crm-button'
|
import { Button } from '@/components/ui/crm-button'
|
||||||
@@ -18,6 +17,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
|||||||
import { statusBadgeLabels } from '@/components/ui/status-badge'
|
import { statusBadgeLabels } from '@/components/ui/status-badge'
|
||||||
import InvoiceDialog from '@/components/documents/InvoiceDialog.vue'
|
import InvoiceDialog from '@/components/documents/InvoiceDialog.vue'
|
||||||
import { hotkey, getPlatformModifierSymbol } from '@/lib/utils'
|
import { hotkey, getPlatformModifierSymbol } from '@/lib/utils'
|
||||||
|
import InvoiceService from '@/services/InvoiceService'
|
||||||
|
|
||||||
// Initial invoice data from inertia (see InvoiceController::show)
|
// Initial invoice data from inertia (see InvoiceController::show)
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -34,8 +34,8 @@ const searchField = ref()
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Load older invoices after initial page load
|
// Load older invoices after initial page load
|
||||||
try {
|
try {
|
||||||
const invoiceBeforeThisYearResponse = await axios.get('/api/invoices/summaryBeforeThisYear')
|
const invoiceBeforeThisYearResponse = await InvoiceService.getSummaryBeforeThisYear()
|
||||||
invoicesData.value = invoicesData.value.concat(invoiceBeforeThisYearResponse.data as Invoice[])
|
invoicesData.value = invoicesData.value.concat(invoiceBeforeThisYearResponse)
|
||||||
invoicesData.value = invoicesData.value.sort(
|
invoicesData.value = invoicesData.value.sort(
|
||||||
(a, b) => new Date(a.invoiceDate).getTime() - new Date(b.invoiceDate).getTime()
|
(a, b) => new Date(a.invoiceDate).getTime() - new Date(b.invoiceDate).getTime()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import axios, { AxiosResponse } from 'axios';
|
||||||
|
import { Invoice, SalesStatistics as SalesStatistics, LineItem } from '@/types';
|
||||||
|
|
||||||
|
const API_URL = '/api/invoices';
|
||||||
|
const LINE_ITEM_API_URL = '/api/lineitems';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
/**
|
||||||
|
* Retrieves all invoices
|
||||||
|
* @returns Promise<Invoice[]>
|
||||||
|
*/
|
||||||
|
async getAllInvoices(): Promise<Invoice[]> {
|
||||||
|
const response = await axios.get<Invoice[]>(API_URL);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves invoice summary for all time
|
||||||
|
* @returns Promise<Invoice[]>
|
||||||
|
*/
|
||||||
|
async getSummaryAll(): Promise<Invoice[]> {
|
||||||
|
const response = await axios.get<Invoice[]>(`${API_URL}/summary`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves invoice summary for the current year
|
||||||
|
* @returns Promise<Invoice[]>
|
||||||
|
*/
|
||||||
|
async getSummaryThisYear(): Promise<Invoice[]> {
|
||||||
|
const response = await axios.get<Invoice[]>(`${API_URL}/summaryThisYear`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves invoice summary for before the current year
|
||||||
|
* @returns Promise<Invoice[]>
|
||||||
|
*/
|
||||||
|
async getSummaryBeforeThisYear(): Promise<Invoice[]> {
|
||||||
|
const response = await axios.get<Invoice[]>(`${API_URL}/summaryBeforeThisYear`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves sales statistics
|
||||||
|
* @returns Promise<InvoiceStatistics>
|
||||||
|
*/
|
||||||
|
async getSalesStatistics(): Promise<SalesStatistics> {
|
||||||
|
const response = await axios.get<SalesStatistics>(`${API_URL}/salesStatistics`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new invoice
|
||||||
|
* @param invoice - The invoice data to create
|
||||||
|
* @returns Promise<Invoice>
|
||||||
|
*/
|
||||||
|
async createInvoice(invoice: Partial<Invoice>): Promise<Invoice> {
|
||||||
|
const response = await axios.post<Invoice>(API_URL, invoice);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing invoice
|
||||||
|
* @param invoice - The invoice object to update
|
||||||
|
* @returns Promise<Invoice>
|
||||||
|
*/
|
||||||
|
async updateInvoice(invoice: Partial<Invoice>): Promise<Invoice> {
|
||||||
|
const response = await axios.put<Invoice>(`${API_URL}/${invoice.id}`, invoice);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an invoice by ID
|
||||||
|
* @param invoiceId - The id of the invoice to delete
|
||||||
|
* @returns Promise<AxiosResponse>
|
||||||
|
*/
|
||||||
|
async deleteInvoice(invoiceId: number): Promise<AxiosResponse> {
|
||||||
|
const response = await axios.delete(`${API_URL}/${invoiceId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves line items for a specific invoice
|
||||||
|
* @param invoiceId - The ID of the invoice
|
||||||
|
* @returns Promise<LineItem[]>
|
||||||
|
*/
|
||||||
|
async getLineItems(invoiceId: number): Promise<LineItem[]> {
|
||||||
|
const response = await axios.get<LineItem[]>(`${LINE_ITEM_API_URL}/${invoiceId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a reminder for an invoice
|
||||||
|
* @param invoiceId - The ID of the invoice to remind
|
||||||
|
* @param to - Email address to send the reminder to
|
||||||
|
* @param cc - Optional email address to CC the reminder to
|
||||||
|
* @returns Promise<AxiosResponse>
|
||||||
|
*/
|
||||||
|
async remindInvoice(invoiceId: number, to: string, cc?: string): Promise<AxiosResponse> {
|
||||||
|
const params = { to, cc };
|
||||||
|
const response = await axios.get(`${API_URL}/${invoiceId}/remind`, { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports an invoice as PDF
|
||||||
|
* @param invoiceId - The ID of the invoice to export
|
||||||
|
* @returns Promise<Blob>
|
||||||
|
*/
|
||||||
|
async exportPdf(invoiceId: number): Promise<Blob> {
|
||||||
|
const response = await axios.get(`${API_URL}/${invoiceId}/pdf`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports an invoice as XML
|
||||||
|
* @param invoiceId - The ID of the invoice to export
|
||||||
|
* @returns Promise<Blob>
|
||||||
|
*/
|
||||||
|
async exportXml(invoiceId: number): Promise<Blob> {
|
||||||
|
const response = await axios.get(`${API_URL}/${invoiceId}/xml`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
Vendored
+15
@@ -277,6 +277,21 @@ export function newInvoice(): Invoice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SalesStatistics {
|
||||||
|
year: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
paid: number;
|
||||||
|
paidPercent: number;
|
||||||
|
draft: number;
|
||||||
|
draftPercent: number;
|
||||||
|
issued: number;
|
||||||
|
issuedPercent: number;
|
||||||
|
due: number;
|
||||||
|
duePercent: number;
|
||||||
|
reminded: number;
|
||||||
|
remindedPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function newBillingData() {
|
export function newBillingData() {
|
||||||
return {
|
return {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,8 @@
|
|||||||
Route::redirect('/', '/dashboard');
|
Route::redirect('/', '/dashboard');
|
||||||
|
|
||||||
Route::get('dashboard', function () {
|
Route::get('dashboard', function () {
|
||||||
return Inertia::render('Dashboard');
|
return Inertia::render('Dashboard',
|
||||||
|
['dueInvoices' => InvoiceController::getDueInvoices()]);
|
||||||
})->name('dashboard');
|
})->name('dashboard');
|
||||||
|
|
||||||
// CRM
|
// CRM
|
||||||
|
|||||||
Reference in New Issue
Block a user