Add "My Invoices" feature with table and pagination components

Introduced a new "My Invoices" section in the user dashboard, including an InvoiceCollectionsTable for managing invoices and a pagination component. Updated navigation menus, routing, and associated utilities to support this feature.
This commit is contained in:
Jepp9350
2025-05-20 12:15:20 +02:00
parent b623332c31
commit 79f1dfa8ed
11 changed files with 583 additions and 5 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,305 @@
<script setup lang="ts">
import {ref, defineProps, defineEmits, watch, computed, onMounted, Component} from 'vue';
import '@creativebulma/bulma-divider/dist/bulma-divider.min.css';
const props = defineProps({
color_class: {
type: String,
default: "has-text-grey"
},
is_loading: {
type: Boolean,
default: false
},
icon_class: {
type: String,
default: "fas fa-circle"
},
error_icon_class: {
type: String,
default: "fas fa-exclamation-triangle"
},
error_class: {
type: String,
default: "has-text-danger"
},
error: {
type: Boolean,
default: false
},
error_message: {
type: String,
default: "An error occurred"
},
dropdown_direction: {
type: String,
default: "down" // Alternative values: "up", "left", "right"
},
loading_message: {
type: String,
default: "Loading..."
},
loading_icon_class: {
type: String,
default: "fas fa-circle-notch"
},
loading_timeout_ms: {
type: Number,
default: 0 // 5 seconds
},
dropdown_content: {
type: Object as () => {
title: string|null;
buttons_title: string|null;
content: Array<{
text: string;
action: () => void;
icon?: string;
classes?: Array<string>;
button?: boolean;
button_text?: string;
button_classes?: Array<string>;
disabled?: boolean;
v_centered?: boolean;
}>;
buttons: Array<{
text: string;
action: () => void;
icon?: string;
classes?: Array<string>;
button_text?: string;
button_classes?: Array<string>;
disabled?: boolean;
v_centered?: boolean;
}>;
},
default: () => ({
title: null,
content: [
/**
{ text: "Item #1", action: () => {}, icon: "fas fa-circle-notch", class: "has-text-info" },
{ text: "Item #2", action: () => {}, icon: "fas fa-circle-notch", class: "has-text-info" },
{ text: "Item #3", action: () => {}, icon: "fas fa-circle-notch", class: "has-text-info" }
*/
],
buttons_title: null,
buttons: [
/**
{ text: "Example button #1", action: () => {}, icon: "fas fa-circle-notch", class: ["has-text-info", "is-small"], button_text: "Button #1", disabled: false },
{ text: "Example button #2", action: () => {}, icon: "fas fa-circle-notch", class: "has-text-info" },
{ text: "Example button #3", action: () => {}, icon: "fas fa-circle-notch", class: "has-text-info" }
*/
]
})
}
});
const emits = defineEmits(['onClick', 'onHover', 'onLeave', 'onScroll', 'onCopyToClipboard', 'onLoadingTimeout']);
const is_currently_hovered = ref(false);
const onClick = () => {
emits('onClick');
};
const onHover = () => {
is_currently_hovered.value = true;
emits('onHover');
};
const onLeave = () => {
is_currently_hovered.value = false;
emits('onLeave');
};
const onScroll = () => {
emits('onScroll');
};
const onLoadingTimeout = () => {
// Check if the element is still loading
if (props.is_loading) {
console.warn('Loading timeout reached, but the element is still loading.');
// If it is still loading, emit the loading timeout event
emits('onLoadingTimeout');
}
}
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
emits('onCopyToClipboard', text);
}).catch(err => {
console.error('Failed to copy: ', err);
});
};
// Watch for scrolling
watch(() => props.is_loading, (newValue) => {
if (newValue) {
window.addEventListener('scroll', onScroll);
} else {
window.removeEventListener('scroll', onScroll);
}
});
// Watch for loading timeout
watch(() => props.loading_timeout_ms, (newValue) => {
if (newValue > 0) {
setTimeout(() => {
onLoadingTimeout();
}, newValue);
}
});
// Watch for loading state
watch(() => props.is_loading, (newValue) => {
if (newValue) {
// If loading, set a timeout to emit the loading timeout event
setTimeout(() => {
onLoadingTimeout();
}, props.loading_timeout_ms);
}
});
// Start the loading timeout when the component is mounted
onMounted(() => {
if (props.is_loading && props.loading_timeout_ms > 0) {
setTimeout(() => {
onLoadingTimeout();
}, props.loading_timeout_ms);
}
});
</script>
<template>
<div class="color-indicator" @click="onClick" @mouseover="onHover" @mouseleave="onLeave">
<span class="icon is-small">
<!-- Loading -->
<template v-if="is_loading">
<i class="fas fa-circle-notch fa-spin"></i>
</template>
<!-- Error state -->
<template v-else-if="error">
<i :class="[error_icon_class, error_class]"></i>
</template>
<!-- Not loading and not hovered -->
<template v-else-if="!is_loading && !is_currently_hovered">
<i :class="[icon_class, color_class]"></i>
</template>
<!-- Not loading and hovered -->
<template v-else-if="!is_loading && is_currently_hovered">
<i :class="[icon_class, color_class]"></i>
</template>
<!-- Loading and hovered -->
<template v-else-if="is_loading && is_currently_hovered">
<i class="fas fa-circle-notch fa-spin"></i>
</template>
<!-- Not loading and hovered -->
<template v-else-if="!is_loading && is_currently_hovered">
<i :class="[icon_class, color_class]"></i>
</template>
<!-- Not loading and not hovered -->
<template v-else-if="!is_loading && !is_currently_hovered">
<i :class="[icon_class, color_class]"></i>
</template>
</span>
<!-- Dropdown menu -->
<div class="dropdown" :class="{ 'is-active': is_currently_hovered, 'is-up': dropdown_direction === 'up', 'is-left': dropdown_direction === 'left', 'is-right': dropdown_direction === 'right' }">
<div class="dropdown-menu">
<div class="dropdown-content" v-if="is_currently_hovered">
<!-- Error message, with copy button -->
<template v-if="error">
<div class="dropdown-item has-text-danger">
<span>
<span class="is-pulled-right">
<button class="button is-small is-light" @click="() => { copyToClipboard(error_message) }">
<i class="fas fa-copy"></i>
</button>
</span>
<span class="has-text-left">
<strong>Error:</strong>
<br />
{{ error_message }}
</span>
</span>
</div>
</template>
<!-- Loading message -->
<template v-else-if="is_loading">
<div class="dropdown-item has-text-info">
<strong>Loading...</strong>
</div>
</template>
<!-- Normal state -->
<template v-else>
<!-- Dropdown content -->
<template v-if="dropdown_content && dropdown_content.content">
<!-- Dropdown label -->
<template v-if="dropdown_content.title">
<div class="dropdown-item has-text-info">
<strong>{{ dropdown_content.title }}</strong>
</div>
<!-- Divider -->
<hr class="dropdown-divider" />
</template>
<template v-for="(item, index) in dropdown_content.content" :key="index">
<!-- Add divider if not the first item -->
<hr class="dropdown-divider" v-if="index > 0" />
<!-- Dropdown item -->
<div class="dropdown-item">
<div class="columns is-mobile" :class="{ 'is-vcentered': item.v_centered }">
<!-- Optional text -->
<div class="column is-auto-fill">
<span class="is-pulled-left">
{{ item.text }}
</span>
</div>
<!-- Optional button -->
<div class="column is-auto-fill" v-if="item.button">
<span class="is-pulled-right">
<button class="button is-small is-light" @click="() => { item.action() }" v-bind:disabled="item.disabled" v-bind:class="item.button_classes">
<!-- Optional button icon -->
<span class="icon" v-if="item.icon"><i :class="[item.icon, (item.classes ? item.classes : [])]"></i></span>
<!-- Optional button text, breaks lines if too long -->
<span v-if="item.button_text" v-bind:class="{ 'ml-2': item.icon }">{{ item.button_text }}</span>
</button>
</span>
</div>
</div>
</div>
</template>
</template>
<!-- Dropdown buttons -->
<template v-if="dropdown_content && dropdown_content.buttons">
<!-- Dropdown label -->
<div class="dropdown-item has-text-info" v-if="dropdown_content.buttons_title">
<strong>{{ dropdown_content.buttons_title }}</strong>
<!-- Divider -->
<hr class="dropdown-divider" />
</div>
<template v-for="(button, index) in dropdown_content.buttons" :key="index">
<div class="dropdown-item">
<span class="is-pulled-right">
<button class="button is-small is-light" @click="() => { button.action() }" v-bind:disabled="button.disabled" v-bind:class="button.button_classes">
<span class="icon"><i :class="[button.icon, (button.classes ? button.classes : [])]"></i></span>
<!-- Optional button text -->
<template v-if="button.button_text">
<span v-if="button.icon" v-bind:class="{ 'ml-2': button.icon }">{{ button.button_text }}</span>
</template>
</button>
</span>
<span class="has-text-left">
{{ button.text }}
</span>
</div>
</template>
</template>
<!-- If there's neither content nor buttons, show a message -->
<template v-if="(!dropdown_content || !dropdown_content.content || dropdown_content.content.length === 0) && (!dropdown_content.buttons || dropdown_content.buttons.length === 0)">
<div class="dropdown-item has-text-info">
<strong>No content available</strong>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
@@ -18,7 +18,7 @@ window.addEventListener('resize', () => {
</script>
<template>
<div>
<div class="py-2">
<div class="columns is-vcentered is-multiline is-mobile">
<!-- Change the number of items per page -->
<div class="column is-narrow my-3" :class="{ 'is-hidden': isSmall }">
@@ -0,0 +1,52 @@
<script setup>
import {
isLoaded,
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setFilter,
getFilter,
setPage,
search,
metaSearch,
setOrder,
meta
} from "@/components/pagination/paginatedList.vue";
import { useRouter } from "vue-router";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import BookingsTable from "@/components/displays/user/bookings/bookingsTable.vue";
import {departments} from "@/components/pagination/departmentTabs.vue";
import {ref, watch} from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import InvoiceCollectionsTable from "@/components/displays/user/invoicecollections/InvoiceCollectionsTable.vue";
const router = useRouter();
setEndpoint("/user/invoices", true);
setOrder("created_at", "desc");
loadList();
</script>
<template>
<input @input="search($event.target.value)" class="input mt-2" type="text" placeholder="Søg efter faktura" v-model="metaSearch" />
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns></template>
</PaginationDisplay>
<InvoiceCollectionsTable :objects="list" :meta="meta" class="mb-4" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" class="mt-3" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">Opdater</LoadButtonWhileAwait>
</template>
<style scoped>
</style>
@@ -0,0 +1,156 @@
<script setup>
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { loadList } from "@/components/pagination/paginatedList.vue";
const props = defineProps({
objects: {
type: Array,
required: true
}
});
import { ref } from 'vue';
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
const su_object = SessionUser.objects.collectedOrderInvoices;
const isInvoiceCancelled = (object) => {
// Check if the invoice has been canceled (There's no orders attached to it)
if (object.orders.length === 0) {
return true
}
};
const canUserEditObject = (object) => {
// Check if the invoice has been booked in Economic, if so, we don't want the user to be able to edit it. (It has already been sent to the customer)
if (object.economic_invoice_booked_id || object.economic_invoice_booked_id === 0) {
return false
}
// Since the object is not marked as closed, we don't want the user making changes that might need to be redone.
if (!object.closed_at) {
return false
}
// Check if the invoice has been canceled (There's no orders attached to it)
if (isInvoiceCancelled(object)) {
return false
}
// All checks passed, we want the user to be able to edit the object.
return true
};
const getObjectColor = (object, is_text) => {
let color = 'grey';
if (object.closed_at) {
color = 'warning';
}
if (object.economic_invoice_booked_id || object.economic_invoice_booked_id === 0) {
color = 'success';
}
if (isInvoiceCancelled(object)) {
color = 'danger';
}
return is_text ? ('has-text-' + color) : ('is-' + color);
};
const getObjectStatus = (object) => {
let status = 'Ikke behandlet';
if (object.closed_at) {
status = 'Ikke faktureret';
}
if (object.economic_invoice_booked_id || object.economic_invoice_booked_id === 0) {
status = 'Faktureret';
}
if (isInvoiceCancelled(object)) {
return 'Annulleret';
}
return status;
};
const dropdown_content_requirements = (object) => {
let requirements = [];
// Check if the user requires a PO number
};
const dropdown_content = (object) => {
return {
title: SessionUser.functions.ucFirst(SessionUser.objects.collectedOrderInvoices.meta.labels.single) + ' ' + object.id,
buttons_title: null,
content: [
// Invoice status
{
text: 'Status',
action: () => {},
classes: [...[(getObjectColor(object, false))]],
button: true,
button_text: getObjectStatus(object),
button_classes: [...[(getObjectColor(object, true))], ...['is-text']],
v_centered: true,
},
// Orders attached to the invoice
{
text: SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.multiple),
action: () => {},
classes: ['has-text-grey'],
button: true,
button_classes: ['has-text-grey', 'is-text'],
v_centered: true,
button_text: object.orders.length > 0 ? object.orders.length : SessionUser.objects.global.language.no_data,
},
],
buttons: []
}
};
</script>
<template>
<div>
<table class="table is-fullwidth">
<thead>
<tr>
<th class="is-narrow"><!--Color indicator--></th>
<th>{{su_object.columns.id.label}}</th>
<th>{{su_object.columns.created_at.label}}</th>
<th>{{su_object.columns.po_number.label}}</th>
<th>{{SessionUser.objects.global.language.total}}</th>
</tr>
</thead>
<tbody>
<template v-for="object in props.objects" :key="object.id">
<tr>
<td><ColorIndicator
:color_class="getObjectColor(object, true)"
:dropdown_content="dropdown_content(object)"
/></td>
<td>{{ object.id }}</td>
<td>{{ object.created_at }}</td>
<!-- PO Number -->
<EditableTableColumn
:object="object"
:loadList="loadList"
column="po_number"
:parse-function="(value) => object.po_number"
:edit-function="SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm"
:permission-check-function="() => canUserEditObject(object)"
/>
<td>{{ SessionUser.functions.currency.toLocal(object.total_net_amount) }}</td>
</tr>
</template>
</tbody>
<tfoot>
<tr>
<td colspan="10">Viser {{ props.objects.length }} transaktioner</td>
</tr>
</tfoot>
</table>
<!-- If there are no objects, show a message -->
<div class="notification is-dark mt-6 mb-6" v-if="props.objects.length === 0">
Der er ingen transaktioner at vise
</div>
</div>
</template>
<style scoped>
</style>
+10 -1
View File
@@ -33,7 +33,16 @@ const menu_items = ref([
route: '/user/bookings',
icon: 'fas fa-calendar-alt',
children: [],
}
},
{
name: 'Mine fakturaer',
route: '/user/invoices',
icon: 'fas fa-file-invoice',
children: [],
permissions: [
'user_invoices'
]
},
]);
</script>
@@ -159,6 +159,14 @@ export const CollectedOrderInvoices = {
required: false
}
},
po_number: {
label: "PO nummer",
type: "string",
sortable: true,
creation: {
required: false
}
},
closed_at: {
label: "Lukket",
type: "date",
@@ -228,6 +236,14 @@ export const CollectedOrderInvoices = {
parseInt(external_id)
)
},
po_number: async (id, po_number) => {
return ObjectsGlobal.set.column(
CollectedOrderInvoices.meta.endpoint,
id,
"po_number",
po_number
)
},
},
get: {
all: async () => {
+7
View File
@@ -99,6 +99,7 @@ import DepartmentTimeBookingsTypes
import DepartmentModulesSetup
from "@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue";
import Vehicle from "@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue";
import MyInvoices from "@/views/dashboards/userDashboard/invoices/MyInvoices.vue";
// Export the router as router
export const router = createRouter({
@@ -153,6 +154,12 @@ export const router = createRouter({
component: MyOrders,
meta: { middleware: authMiddleware }
},
{
name: 'myinvoices',
path: '/user/invoices',
component: MyInvoices,
meta: { middleware: authMiddleware }
},
{
name: 'myorder',
path: '/user/orders/:orderId',
+17 -2
View File
@@ -1,7 +1,7 @@
<script setup>
import { Colors } from "@/ThemeConfig.vue";
import {defineProps, ref} from "vue";
import {defineProps, ref, computed, watch} from "vue";
import { defineSlots } from "vue";
import UserNavigationTop from "@/components/displays/user/UserNavigationTop.vue";
import Footer from "@/components/global/Footer.vue";
@@ -18,6 +18,18 @@ const IS_EXPANDED = ref(false)
const TOGGLE_EXPANDED = () => {
IS_EXPANDED.value = !IS_EXPANDED.value
}
const isMobile = ref(window.innerWidth < 768)
const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
window.addEventListener('resize', handleResize)
const applyScreenDependingClasses = (shouldApply, classes) => {
return shouldApply ? classes : []
}
</script>
<template>
@@ -107,7 +119,10 @@ const TOGGLE_EXPANDED = () => {
:style="{ 'background-color': Colors.global.backgroundColor, 'color': Colors.global.textColor }"
style="min-height: 100vh;"
>
<div class="pl-6 pr-6 pb-6">
<div v-bind:class="[
...applyScreenDependingClasses(!isMobile, ['pl-6', 'pr-6', 'pb-6']),
...applyScreenDependingClasses(isMobile, ['pl-2', 'pr-2', 'pb-2']),
]">
<!-- Buttons slot -->
<template v-if="slots.buttons">
<slot name="buttons"></slot>
@@ -0,0 +1,18 @@
<script setup>
import BookingsPagination from "@/components/displays/pagination/models/UserDashboard/BookingsPagination.vue";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import MyInvoicesPagination from "@/components/displays/pagination/models/UserDashboard/MyInvoicesPagination.vue";
</script>
<template>
<div>
<UserDashboardPageWrapper title="Mine fakturaer" subtitle="Se dine fakturaer">
<MyInvoicesPagination />
</UserDashboardPageWrapper>
</div>
</template>
<style scoped>
</style>