Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26da10677 |
@@ -140,7 +140,6 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -153,13 +152,8 @@ jobs:
|
||||
else
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
fi
|
||||
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_HEAD_SHA" ]]; then
|
||||
head_ref="$PR_HEAD_SHA"
|
||||
else
|
||||
head_ref="$HEAD_SHA"
|
||||
fi
|
||||
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
|
||||
@@ -33,16 +33,6 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "superuser-department-overview",
|
||||
patterns: [
|
||||
/^src\/services\/superuserDepartmentOverview\.js$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
|
||||
],
|
||||
specs: ["tests/e2e/superuser-department-overview.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "booking",
|
||||
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
||||
|
||||
@@ -101,7 +101,6 @@ export const ownedFilesByRole = {
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-customers-mass-import.spec.ts",
|
||||
"superuser-department-branding.spec.js",
|
||||
"superuser-department-overview.spec.js",
|
||||
"superuser-department-gates.spec.ts",
|
||||
"superuser-department-lanes.spec.ts",
|
||||
"superuser-departments-archive.spec.ts",
|
||||
|
||||
@@ -12,6 +12,11 @@ import UserOtherVaskeabonnement
|
||||
import Swal from "sweetalert2";
|
||||
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
|
||||
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
||||
import {
|
||||
buildMultiMonthInvoiceContext,
|
||||
MULTI_MONTH_INVOICE_ACTION,
|
||||
promptMultiMonthInvoiceWarning,
|
||||
} from "@/services/invoiceMonthSplitWarning.js";
|
||||
|
||||
const props = defineProps({
|
||||
orders: {
|
||||
@@ -126,32 +131,34 @@ const isAnyOrderSelected = () => {
|
||||
return selectedInvoiceCollections.value.length > 0;
|
||||
}
|
||||
|
||||
const getSelectedOrders = () => {
|
||||
return props.orders.filter((order) => selectedInvoiceCollections.value.includes(order.invoice_collection_id));
|
||||
}
|
||||
|
||||
/** Invoice collections */
|
||||
const onInvoiceCollections = async () => {
|
||||
// Check if any orders are selected
|
||||
if (selectedInvoiceCollections.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
/** Create the invoice */
|
||||
const onCreateInvoiceDraft = async () => {
|
||||
// Create the invoice
|
||||
console.log('Create invoice');
|
||||
await SessionUser.objects.collectedOrderInvoices.functions.economic.invoice(parseInt(props.collectedOrderInvoice.id)).then((response) => {
|
||||
console.log('Invoice created successfully', response);
|
||||
Swal.fire({
|
||||
title: 'Fakturaen er oprettet',
|
||||
text: 'Fakturaen er oprettet i E-conomic',
|
||||
icon: 'success',
|
||||
showConfirmButton: false,
|
||||
timer: 2000
|
||||
}).then(() => {
|
||||
location.reload();
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.log('Error creating invoice', error);
|
||||
}
|
||||
)
|
||||
const selectedOrders = getSelectedOrders();
|
||||
const invoiceWarningContext = buildMultiMonthInvoiceContext(selectedOrders, {
|
||||
getDate: (order) => order?.created_at ?? order?.date,
|
||||
getInvoiceCollectionId: (order) => order?.invoice_collection_id,
|
||||
});
|
||||
const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
|
||||
context: invoiceWarningContext,
|
||||
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
|
||||
parseErrorMessage: SessionUser.functions.parseErrorMessage,
|
||||
});
|
||||
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < selectedInvoiceCollections.value.length; i++) {
|
||||
const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i];
|
||||
// Check if the invoice collection is already booked
|
||||
@@ -210,6 +217,7 @@ const isOrderContentVisible = (order) => {
|
||||
class="button is-small"
|
||||
@click="onInvoiceCollections()"
|
||||
:disabled="!isAnyOrderSelected()"
|
||||
data-testid="invoice-order-table-invoice-button"
|
||||
>
|
||||
{{ $t('global.invoice_now') }}
|
||||
</button>
|
||||
@@ -274,4 +282,4 @@ const isOrderContentVisible = (order) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -444,10 +444,14 @@ export const CollectedOrderInvoices = {
|
||||
});
|
||||
},
|
||||
split_by_month: async (dateFrom, dateTo, options = {}) => {
|
||||
const invoiceCollectionIds = Array.isArray(options.invoiceCollectionIds)
|
||||
? options.invoiceCollectionIds
|
||||
: options.invoice_collection_ids;
|
||||
return authenticatedRequest('/collected-invoices/split-by-month', 'POST', {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
...(options.preview !== undefined ? { preview: !!options.preview } : {}),
|
||||
...(Array.isArray(invoiceCollectionIds) ? { invoice_collection_ids: invoiceCollectionIds } : {}),
|
||||
}).then((response) => {
|
||||
console.log(response);
|
||||
return response;
|
||||
|
||||
@@ -4028,6 +4028,15 @@
|
||||
"preview_title": "@:{'words.generated.forhandsvis'} @:{'words.generated.manedsopdeling'}",
|
||||
"success_text": "@:{'words.generated.behandlede'} {processed} @:{'words.generated.fakturasamlinger'}. Opdelte {changed} @:{'words.generated.og'} sprang {skipped} @:{'words.generated.over'}.",
|
||||
"success_title": "@.capitalize:{'words.generated.manedsopdeling'} @:{'words.generated.fuldført'}"
|
||||
},
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Fakturer sammen",
|
||||
"split_by_month": "Opdel efter måned",
|
||||
"split_error_title": "Månedsopdeling mislykkedes",
|
||||
"split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
|
||||
"split_success_title": "Månedsopdeling fuldført",
|
||||
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
|
||||
"title": "Ordrer fra flere måneder"
|
||||
}
|
||||
},
|
||||
"invoicing": {
|
||||
|
||||
@@ -4139,6 +4139,15 @@
|
||||
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
|
||||
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
|
||||
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
|
||||
},
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Zusammen abrechnen",
|
||||
"split_by_month": "Nach Monat aufteilen",
|
||||
"split_error_title": "Monatsaufteilung fehlgeschlagen",
|
||||
"split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.",
|
||||
"split_success_title": "Monatsaufteilung abgeschlossen",
|
||||
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
|
||||
"title": "Aufträge aus mehreren Monaten"
|
||||
}
|
||||
},
|
||||
"invoicing": {
|
||||
|
||||
@@ -3860,6 +3860,15 @@
|
||||
"preview_title": "@.capitalize:{'words.generated.preview'} @:{'words.generated.monthly'} @:{'words.generated.split'}",
|
||||
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
|
||||
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
|
||||
},
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Invoice together",
|
||||
"split_by_month": "Split by month",
|
||||
"split_error_title": "Monthly split failed",
|
||||
"split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
|
||||
"split_success_title": "Monthly split completed",
|
||||
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
|
||||
"title": "Orders from multiple months"
|
||||
}
|
||||
},
|
||||
"invoicing": {
|
||||
|
||||
@@ -3149,6 +3149,15 @@
|
||||
"preview_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.preview_title'}",
|
||||
"success_text": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_text'}",
|
||||
"success_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_title'}"
|
||||
},
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}",
|
||||
"split_by_month": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}",
|
||||
"split_error_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}",
|
||||
"split_success_text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}",
|
||||
"split_success_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
|
||||
"text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.text'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.title'}"
|
||||
}
|
||||
},
|
||||
"invoicing": {
|
||||
@@ -4559,82 +4568,6 @@
|
||||
"subtitle": "@:{'templates.generated.compat.superuser_dashboard.departments.subtitle'}",
|
||||
"title": "@:common.departments"
|
||||
},
|
||||
"department_navigation": {
|
||||
"overview": "Overview",
|
||||
"modules": "Modules",
|
||||
"branding": "Profile & Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"department_overview": {
|
||||
"title": "Department overview",
|
||||
"subtitle": "Operational overview for the selected department",
|
||||
"loading": "Loading department overview",
|
||||
"date_from": "From",
|
||||
"date_to": "To",
|
||||
"range_label": "{from} to {to}",
|
||||
"empty_value": "-",
|
||||
"out_of": "of {total}",
|
||||
"presets": {
|
||||
"today": "Today",
|
||||
"last_seven_days": "Last 7 days"
|
||||
},
|
||||
"metrics": {
|
||||
"bookings": "Bookings",
|
||||
"complaints": "Complaints",
|
||||
"night_washes": "Night washes",
|
||||
"overtime": "Overtime",
|
||||
"products_sold": "Products sold",
|
||||
"revenue": "Revenue",
|
||||
"transactions": "Transactions",
|
||||
"washes": "Washes",
|
||||
"water_usage": "Water"
|
||||
},
|
||||
"units": {
|
||||
"hours": "h",
|
||||
"liters": "L"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product mix",
|
||||
"subtitle": "Tracked wash products in the selected period",
|
||||
"empty": "No product activity for the selected period"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Department profile",
|
||||
"no_description": "No department description",
|
||||
"department_id": "Department ID",
|
||||
"economic_department_id": "Economic department",
|
||||
"branding": "Branding",
|
||||
"created_at": "Created",
|
||||
"updated_at": "Updated"
|
||||
},
|
||||
"hardware": {
|
||||
"title": "Hardware readiness",
|
||||
"subtitle": "Gateway-backed lane and relay state",
|
||||
"gateways": "Gateways online",
|
||||
"lanes": "Lanes",
|
||||
"gates": "Gates",
|
||||
"relays": "Relays",
|
||||
"scanners": "Scanners",
|
||||
"issues": "Issues"
|
||||
},
|
||||
"quick_links": {
|
||||
"title": "Department tools",
|
||||
"subtitle": "Open the focused setup areas for this department",
|
||||
"modules": "Modules",
|
||||
"branding": "Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"errors": {
|
||||
"invalid_department": "A valid department is required",
|
||||
"load": "Unable to load the department overview"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
"edit_employee_title": "@:employees.edit_employee",
|
||||
"new_employee_subtitle": "@:{'templates.generated.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
||||
|
||||
@@ -4142,6 +4142,15 @@
|
||||
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
|
||||
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
|
||||
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
|
||||
},
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Fakturer samlet",
|
||||
"split_by_month": "Del opp etter måned",
|
||||
"split_error_title": "Månedsdeling mislyktes",
|
||||
"split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.",
|
||||
"split_success_title": "Månedsdeling fullført",
|
||||
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
|
||||
"title": "Ordrer fra flere måneder"
|
||||
}
|
||||
},
|
||||
"invoicing": {
|
||||
|
||||
@@ -4192,6 +4192,15 @@
|
||||
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
|
||||
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
|
||||
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
|
||||
},
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Fakturera tillsammans",
|
||||
"split_by_month": "Dela upp per månad",
|
||||
"split_error_title": "Månadsuppdelning misslyckades",
|
||||
"split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.",
|
||||
"split_success_title": "Månadsuppdelning klar",
|
||||
"text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?",
|
||||
"title": "Ordrar från flera månader"
|
||||
}
|
||||
},
|
||||
"invoicing": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Fakturer sammen",
|
||||
"split_by_month": "Opdel efter måned",
|
||||
"split_error_title": "Månedsopdeling mislykkedes",
|
||||
"split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
|
||||
"split_success_title": "Månedsopdeling fuldført",
|
||||
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
|
||||
"title": "Ordrer fra flere måneder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Zusammen abrechnen",
|
||||
"split_by_month": "Nach Monat aufteilen",
|
||||
"split_error_title": "Monatsaufteilung fehlgeschlagen",
|
||||
"split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.",
|
||||
"split_success_title": "Monatsaufteilung abgeschlossen",
|
||||
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
|
||||
"title": "Aufträge aus mehreren Monaten"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Invoice together",
|
||||
"split_by_month": "Split by month",
|
||||
"split_error_title": "Monthly split failed",
|
||||
"split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
|
||||
"split_success_title": "Monthly split completed",
|
||||
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
|
||||
"title": "Orders from multiple months"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"invoicing_period": {
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}",
|
||||
"split_by_month": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}",
|
||||
"split_error_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}",
|
||||
"split_success_text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}",
|
||||
"split_success_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
|
||||
"text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.text'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.title'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,82 +40,6 @@
|
||||
"subtitle": "@:{'phrases.compat.superuser_dashboard.departments.subtitle'}",
|
||||
"title": "@:common.departments"
|
||||
},
|
||||
"department_navigation": {
|
||||
"overview": "Overview",
|
||||
"modules": "Modules",
|
||||
"branding": "Profile & Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"department_overview": {
|
||||
"title": "Department overview",
|
||||
"subtitle": "Operational overview for the selected department",
|
||||
"loading": "Loading department overview",
|
||||
"date_from": "From",
|
||||
"date_to": "To",
|
||||
"range_label": "{from} to {to}",
|
||||
"empty_value": "-",
|
||||
"out_of": "of {total}",
|
||||
"presets": {
|
||||
"today": "Today",
|
||||
"last_seven_days": "Last 7 days"
|
||||
},
|
||||
"metrics": {
|
||||
"bookings": "Bookings",
|
||||
"complaints": "Complaints",
|
||||
"night_washes": "Night washes",
|
||||
"overtime": "Overtime",
|
||||
"products_sold": "Products sold",
|
||||
"revenue": "Revenue",
|
||||
"transactions": "Transactions",
|
||||
"washes": "Washes",
|
||||
"water_usage": "Water"
|
||||
},
|
||||
"units": {
|
||||
"hours": "h",
|
||||
"liters": "L"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product mix",
|
||||
"subtitle": "Tracked wash products in the selected period",
|
||||
"empty": "No product activity for the selected period"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Department profile",
|
||||
"no_description": "No department description",
|
||||
"department_id": "Department ID",
|
||||
"economic_department_id": "Economic department",
|
||||
"branding": "Branding",
|
||||
"created_at": "Created",
|
||||
"updated_at": "Updated"
|
||||
},
|
||||
"hardware": {
|
||||
"title": "Hardware readiness",
|
||||
"subtitle": "Gateway-backed lane and relay state",
|
||||
"gateways": "Gateways online",
|
||||
"lanes": "Lanes",
|
||||
"gates": "Gates",
|
||||
"relays": "Relays",
|
||||
"scanners": "Scanners",
|
||||
"issues": "Issues"
|
||||
},
|
||||
"quick_links": {
|
||||
"title": "Department tools",
|
||||
"subtitle": "Open the focused setup areas for this department",
|
||||
"modules": "Modules",
|
||||
"branding": "Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"errors": {
|
||||
"invalid_department": "A valid department is required",
|
||||
"load": "Unable to load the department overview"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
"edit_employee_title": "@:employees.edit_employee",
|
||||
"new_employee_subtitle": "@:{'phrases.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Fakturer samlet",
|
||||
"split_by_month": "Del opp etter måned",
|
||||
"split_error_title": "Månedsdeling mislyktes",
|
||||
"split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.",
|
||||
"split_success_title": "Månedsdeling fullført",
|
||||
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
|
||||
"title": "Ordrer fra flere måneder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"multi_month_invoice_warning": {
|
||||
"invoice_together": "Fakturera tillsammans",
|
||||
"split_by_month": "Dela upp per månad",
|
||||
"split_error_title": "Månadsuppdelning misslyckades",
|
||||
"split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.",
|
||||
"split_success_title": "Månadsuppdelning klar",
|
||||
"text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?",
|
||||
"title": "Ordrar från flera månader"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Swal from "sweetalert2";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export const MULTI_MONTH_INVOICE_ACTION = {
|
||||
CONTINUE: "continue",
|
||||
CANCEL: "cancel",
|
||||
SPLIT: "split",
|
||||
};
|
||||
|
||||
const toPositiveInteger = (value) => {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const getDateKey = (value) => {
|
||||
const rawValue = String(value ?? "").trim();
|
||||
const directMatch = rawValue.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (directMatch) {
|
||||
return `${directMatch[1]}-${directMatch[2]}-${directMatch[3]}`;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(rawValue);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = parsedDate.getFullYear();
|
||||
const month = String(parsedDate.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(parsedDate.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
export const getMonthKey = (value) => {
|
||||
const dateKey = getDateKey(value);
|
||||
return dateKey ? dateKey.slice(0, 7) : null;
|
||||
};
|
||||
|
||||
const translate = (key, params = {}) => i18n.global.t(key, params);
|
||||
|
||||
const getSplitResponsePayload = (response = {}) => response?.data?.data ?? response?.data ?? {};
|
||||
|
||||
export const buildMultiMonthInvoiceContext = (
|
||||
items = [],
|
||||
{
|
||||
getDate = (item) => item?.created_at ?? item?.date,
|
||||
getInvoiceCollectionId = (item) => item?.invoice_collection_id,
|
||||
} = {}
|
||||
) => {
|
||||
const months = new Set();
|
||||
const dateKeys = [];
|
||||
const invoiceCollectionIds = new Set();
|
||||
|
||||
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||
const dateValue = getDate(item);
|
||||
const dateKey = getDateKey(dateValue);
|
||||
if (dateKey) {
|
||||
dateKeys.push(dateKey);
|
||||
months.add(dateKey.slice(0, 7));
|
||||
}
|
||||
|
||||
const invoiceCollectionId = toPositiveInteger(getInvoiceCollectionId(item));
|
||||
if (invoiceCollectionId) {
|
||||
invoiceCollectionIds.add(invoiceCollectionId);
|
||||
}
|
||||
});
|
||||
|
||||
dateKeys.sort();
|
||||
|
||||
return {
|
||||
months: Array.from(months).sort(),
|
||||
dateFrom: dateKeys[0] ?? null,
|
||||
dateTo: dateKeys[dateKeys.length - 1] ?? null,
|
||||
invoiceCollectionIds: Array.from(invoiceCollectionIds).sort((left, right) => left - right),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldWarnAboutMultiMonthInvoice = (context = {}) => (
|
||||
Array.isArray(context.months) &&
|
||||
context.months.length > 1 &&
|
||||
Array.isArray(context.invoiceCollectionIds) &&
|
||||
context.invoiceCollectionIds.length > 0 &&
|
||||
Boolean(context.dateFrom) &&
|
||||
Boolean(context.dateTo)
|
||||
);
|
||||
|
||||
export const promptMultiMonthInvoiceWarning = async ({
|
||||
context,
|
||||
splitByMonth,
|
||||
parseErrorMessage = (error) => error?.message ?? String(error),
|
||||
} = {}) => {
|
||||
if (!shouldWarnAboutMultiMonthInvoice(context)) {
|
||||
return MULTI_MONTH_INVOICE_ACTION.CONTINUE;
|
||||
}
|
||||
|
||||
const months = context.months.join(", ");
|
||||
const confirmation = await Swal.fire({
|
||||
icon: "warning",
|
||||
title: translate("invoicing_period.multi_month_invoice_warning.title"),
|
||||
text: translate("invoicing_period.multi_month_invoice_warning.text", { months }),
|
||||
showCancelButton: true,
|
||||
showDenyButton: true,
|
||||
confirmButtonText: translate("invoicing_period.multi_month_invoice_warning.split_by_month"),
|
||||
denyButtonText: translate("invoicing_period.multi_month_invoice_warning.invoice_together"),
|
||||
cancelButtonText: translate("common.cancel"),
|
||||
});
|
||||
|
||||
if (confirmation.isDenied) {
|
||||
return MULTI_MONTH_INVOICE_ACTION.CONTINUE;
|
||||
}
|
||||
|
||||
if (!confirmation.isConfirmed) {
|
||||
return MULTI_MONTH_INVOICE_ACTION.CANCEL;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await splitByMonth(context.dateFrom, context.dateTo, {
|
||||
invoiceCollectionIds: context.invoiceCollectionIds,
|
||||
preview: false,
|
||||
});
|
||||
const result = getSplitResponsePayload(response);
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: translate("invoicing_period.multi_month_invoice_warning.split_success_title"),
|
||||
text: translate("invoicing_period.multi_month_invoice_warning.split_success_text", {
|
||||
processed: result.processed_count ?? 0,
|
||||
changed: result.changed_count ?? 0,
|
||||
skipped: result.skipped_count ?? 0,
|
||||
}),
|
||||
});
|
||||
return MULTI_MONTH_INVOICE_ACTION.SPLIT;
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: translate("invoicing_period.multi_month_invoice_warning.split_error_title"),
|
||||
text: parseErrorMessage(error),
|
||||
});
|
||||
return MULTI_MONTH_INVOICE_ACTION.CANCEL;
|
||||
}
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
export const getSuperuserDepartmentOverview = (departmentId, { date, dateTo } = {}) => {
|
||||
const normalizedDepartmentId = Number.parseInt(String(departmentId ?? ""), 10);
|
||||
|
||||
if (!Number.isInteger(normalizedDepartmentId) || normalizedDepartmentId <= 0) {
|
||||
return Promise.reject(new Error("A valid department id is required."));
|
||||
}
|
||||
|
||||
const params = {
|
||||
date,
|
||||
};
|
||||
|
||||
if (dateTo) {
|
||||
params.date_to = dateTo;
|
||||
}
|
||||
|
||||
return authenticatedRequest(
|
||||
`/superuser/departments/${encodeURIComponent(String(normalizedDepartmentId))}/overview`,
|
||||
"GET",
|
||||
params
|
||||
);
|
||||
};
|
||||
+36
-4
@@ -22,6 +22,11 @@ import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard
|
||||
import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
|
||||
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
import {
|
||||
buildMultiMonthInvoiceContext,
|
||||
MULTI_MONTH_INVOICE_ACTION,
|
||||
promptMultiMonthInvoiceWarning,
|
||||
} from "@/services/invoiceMonthSplitWarning.js";
|
||||
import {
|
||||
buildPossibleDuplicateGroups,
|
||||
formatDuplicateDateLabel,
|
||||
@@ -330,6 +335,13 @@ const parsePositiveInteger = (value: any) => {
|
||||
|
||||
const getCustomerNumber = (customer: any) => parsePositiveInteger(customer?.customer_number);
|
||||
|
||||
const markCustomerPeriodRefreshLoading = (customer: any) => {
|
||||
const customerNumber = getCustomerNumber(customer);
|
||||
if (customerNumber) {
|
||||
invoiceQueue.markPeriodRefreshLoading?.([customerNumber], []);
|
||||
}
|
||||
};
|
||||
|
||||
const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any = null) => {
|
||||
const uniqueInvoiceCollectionIds = Array.from(
|
||||
new Set(
|
||||
@@ -351,12 +363,10 @@ const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any =
|
||||
|
||||
const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
|
||||
const customerNumber = getCustomerNumber(customer);
|
||||
if (customerNumber) {
|
||||
invoiceQueue.markPeriodRefreshLoading?.([customerNumber], []);
|
||||
}
|
||||
|
||||
try {
|
||||
if (transactionIds.length === 0) {
|
||||
markCustomerPeriodRefreshLoading(customer);
|
||||
const month = dates.variables.start.value.getMonth() + 1;
|
||||
const year = dates.variables.start.value.getFullYear();
|
||||
|
||||
@@ -392,10 +402,32 @@ const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
|
||||
await fetchMissingInvoiceCollections(customer, transactionIds);
|
||||
const invoiceCollectionIds = getInvoiceCollectionIdsForTransactionIds(customer, transactionIds);
|
||||
if (invoiceCollectionIds.length === 0) {
|
||||
invoiceQueue.finishPeriodRefresh?.([customerNumber], []);
|
||||
return;
|
||||
}
|
||||
|
||||
const invoiceWarningContext = buildMultiMonthInvoiceContext(
|
||||
transactionIds
|
||||
.map((transactionId) => getTransactionById(customer, transactionId))
|
||||
.filter((transaction: any) => transaction !== null),
|
||||
{
|
||||
getDate: (transaction: any) => transaction?.date ?? transaction?.created_at,
|
||||
getInvoiceCollectionId: (transaction: any) => getTransactionInvoiceCollectionId(transaction),
|
||||
}
|
||||
);
|
||||
const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
|
||||
context: invoiceWarningContext,
|
||||
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
|
||||
parseErrorMessage: SessionUser.functions.parseErrorMessage,
|
||||
});
|
||||
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
|
||||
reloadPeriodPage();
|
||||
return;
|
||||
}
|
||||
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
markCustomerPeriodRefreshLoading(customer);
|
||||
queueInvoiceCollections(invoiceCollectionIds, customer);
|
||||
} catch (error: any) {
|
||||
invoiceQueue.finishPeriodRefresh?.([customerNumber], []);
|
||||
|
||||
@@ -2,871 +2,34 @@
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { getEdgeGatewayDepartmentWorkspace } from "@/services/edgeGateways.js";
|
||||
import { getSuperuserDepartmentOverview } from "@/services/superuserDepartmentOverview.js";
|
||||
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { setDepartment, department, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const formatDateInput = (value = new Date()) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return formatDateInput(new Date());
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const addDays = (value, days) => {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return formatDateInput(date);
|
||||
};
|
||||
|
||||
const today = () => formatDateInput(new Date());
|
||||
const isDateValue = (value) => DATE_PATTERN.test(String(value || ""));
|
||||
const normalizeDate = (value, fallback = today()) => (isDateValue(value) ? String(value) : fallback);
|
||||
|
||||
const dateFrom = ref(normalizeDate(route.query.date));
|
||||
const dateTo = ref(normalizeDate(route.query.date_to, dateFrom.value));
|
||||
const department = ref(null);
|
||||
const overview = ref(null);
|
||||
const hardwareWorkspace = ref(null);
|
||||
const loading = ref(false);
|
||||
const hardwareLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const departmentId = computed(() => Number.parseInt(String(route.params.departmentId || ""), 10));
|
||||
const hasDepartmentId = computed(() => Number.isInteger(departmentId.value) && departmentId.value > 0);
|
||||
const pageTitle = computed(() => department.value?.name || t("superuser_dashboard.department_overview.title"));
|
||||
const pageSubtitle = computed(() => t("superuser_dashboard.department_overview.subtitle"));
|
||||
const overviewMetrics = computed(() => overview.value?.metrics || {});
|
||||
const productTiles = computed(() => (Array.isArray(overview.value?.products) ? overview.value.products : []));
|
||||
const periodLabel = computed(() =>
|
||||
dateFrom.value === dateTo.value
|
||||
? dateFrom.value
|
||||
: t("superuser_dashboard.department_overview.range_label", {
|
||||
from: dateFrom.value,
|
||||
to: dateTo.value,
|
||||
})
|
||||
);
|
||||
|
||||
const metricDefinitions = computed(() => [
|
||||
{
|
||||
key: "revenue",
|
||||
icon: "fa-sack-dollar",
|
||||
label: t("superuser_dashboard.department_overview.metrics.revenue"),
|
||||
formatter: formatCurrency,
|
||||
},
|
||||
{
|
||||
key: "washes",
|
||||
icon: "fa-truck",
|
||||
label: t("superuser_dashboard.department_overview.metrics.washes"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "transactions",
|
||||
icon: "fa-receipt",
|
||||
label: t("superuser_dashboard.department_overview.metrics.transactions"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "bookings",
|
||||
icon: "fa-calendar-check",
|
||||
label: t("superuser_dashboard.department_overview.metrics.bookings"),
|
||||
formatter: formatNumber,
|
||||
showOutOf: true,
|
||||
},
|
||||
{
|
||||
key: "products_sold",
|
||||
icon: "fa-boxes-stacked",
|
||||
label: t("superuser_dashboard.department_overview.metrics.products_sold"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "water_usage",
|
||||
icon: "fa-droplet",
|
||||
label: t("superuser_dashboard.department_overview.metrics.water_usage"),
|
||||
formatter: formatNumber,
|
||||
suffix: t("superuser_dashboard.department_overview.units.liters"),
|
||||
},
|
||||
{
|
||||
key: "complaints",
|
||||
icon: "fa-triangle-exclamation",
|
||||
label: t("superuser_dashboard.department_overview.metrics.complaints"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "night_washes",
|
||||
icon: "fa-moon",
|
||||
label: t("superuser_dashboard.department_overview.metrics.night_washes"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "overtime",
|
||||
icon: "fa-clock",
|
||||
label: t("superuser_dashboard.department_overview.metrics.overtime"),
|
||||
formatter: formatDecimal,
|
||||
suffix: t("superuser_dashboard.department_overview.units.hours"),
|
||||
},
|
||||
]);
|
||||
|
||||
const quickLinks = computed(() => [
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.modules"),
|
||||
icon: "fa-toggle-on",
|
||||
path: `/superuser/departments/${departmentId.value}/modules`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.branding"),
|
||||
icon: "fa-palette",
|
||||
path: `/superuser/departments/${departmentId.value}/branding`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.gateways"),
|
||||
icon: "fa-network-wired",
|
||||
path: `/superuser/departments/${departmentId.value}/gateways`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.stripe"),
|
||||
icon: "fa-credit-card",
|
||||
path: `/superuser/departments/${departmentId.value}/stripe/terminals/readers`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.pricing"),
|
||||
icon: "fa-tags",
|
||||
path: `/superuser/departments/${departmentId.value}/pricing`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.categories"),
|
||||
icon: "fa-layer-group",
|
||||
path: `/superuser/departments/${departmentId.value}/categories`,
|
||||
},
|
||||
]);
|
||||
|
||||
function toNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("da-DK").format(toNumber(value));
|
||||
}
|
||||
|
||||
function formatDecimal(value) {
|
||||
return new Intl.NumberFormat("da-DK", {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(toNumber(value));
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
const amount = toNumber(value);
|
||||
if (SessionUser.functions?.currency?.toLocal) {
|
||||
return SessionUser.functions.currency.toLocal(amount);
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("da-DK", {
|
||||
style: "currency",
|
||||
currency: "DKK",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return t("superuser_dashboard.department_overview.empty_value");
|
||||
}
|
||||
|
||||
if (SessionUser.functions?.date?.toLocal) {
|
||||
return SessionUser.functions.date.toLocal(value);
|
||||
}
|
||||
|
||||
const date = new Date(String(value).replace(" ", "T"));
|
||||
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat("da-DK").format(date);
|
||||
}
|
||||
|
||||
function metricPayload(key) {
|
||||
return overviewMetrics.value?.[key] || {
|
||||
state: "unavailable",
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
function metricValue(definition) {
|
||||
const metric = metricPayload(definition.key);
|
||||
if (metric.state && metric.state !== "ready") {
|
||||
return t("superuser_dashboard.department_overview.empty_value");
|
||||
}
|
||||
|
||||
const formatted = definition.formatter(metric.value);
|
||||
return definition.suffix ? `${formatted} ${definition.suffix}` : formatted;
|
||||
}
|
||||
|
||||
function metricSecondary(definition) {
|
||||
const metric = metricPayload(definition.key);
|
||||
if (!definition.showOutOf || metric.out_of === undefined || metric.out_of === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return t("superuser_dashboard.department_overview.out_of", {
|
||||
total: formatNumber(metric.out_of),
|
||||
});
|
||||
}
|
||||
|
||||
const asArray = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const hardwareSummary = computed(() => {
|
||||
const workspace = hardwareWorkspace.value || {};
|
||||
const gateways = asArray(workspace.gateways);
|
||||
const lanes = asArray(workspace.lanes);
|
||||
const gates = asArray(workspace.gates);
|
||||
const relays = asArray(workspace.relays);
|
||||
const scanners = asArray(workspace.scanners || workspace.number_plate_scanners);
|
||||
const issues = asArray(workspace.issues || workspace.diagnostics || workspace.hardware?.issues);
|
||||
const onlineGateways = gateways.filter((gateway) => {
|
||||
const status = String(gateway?.status || gateway?.health || "").toLowerCase();
|
||||
return ["online", "ready", "ok", "healthy"].includes(status);
|
||||
}).length;
|
||||
|
||||
return {
|
||||
gateways: gateways.length,
|
||||
onlineGateways,
|
||||
lanes: lanes.length,
|
||||
gates: gates.length,
|
||||
relays: relays.length,
|
||||
scanners: scanners.length,
|
||||
issues: issues.length,
|
||||
};
|
||||
});
|
||||
|
||||
const hasHardwareSummary = computed(() => hardwareLoading.value || Boolean(hardwareWorkspace.value));
|
||||
const departmentProfileRows = computed(() => [
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.department_id"),
|
||||
value: department.value?.id ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.economic_department_id"),
|
||||
value: department.value?.economic_department_id ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.branding"),
|
||||
value: department.value?.branding ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.created_at"),
|
||||
value: formatDateTime(department.value?.created_at),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.updated_at"),
|
||||
value: formatDateTime(department.value?.updated_at),
|
||||
},
|
||||
]);
|
||||
|
||||
function normalizeRange(from, to) {
|
||||
const normalizedFrom = normalizeDate(from);
|
||||
let normalizedTo = normalizeDate(to, normalizedFrom);
|
||||
|
||||
if (normalizedTo < normalizedFrom) {
|
||||
normalizedTo = normalizedFrom;
|
||||
}
|
||||
|
||||
return {
|
||||
from: normalizedFrom,
|
||||
to: normalizedTo,
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceRange(from, to) {
|
||||
const normalized = normalizeRange(from, to);
|
||||
dateFrom.value = normalized.from;
|
||||
dateTo.value = normalized.to;
|
||||
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
date: normalized.from,
|
||||
date_to: normalized.to === normalized.from ? undefined : normalized.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const applyDateInputs = () => replaceRange(dateFrom.value, dateTo.value);
|
||||
const setToday = () => replaceRange(today(), today());
|
||||
const setLastSevenDays = () => replaceRange(addDays(today(), -6), today());
|
||||
|
||||
async function loadOverview() {
|
||||
if (!hasDepartmentId.value) {
|
||||
errorMessage.value = t("superuser_dashboard.department_overview.errors.invalid_department");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await getSuperuserDepartmentOverview(departmentId.value, {
|
||||
date: dateFrom.value,
|
||||
dateTo: dateTo.value,
|
||||
});
|
||||
const payload = response?.data?.data || {};
|
||||
department.value = payload.department || null;
|
||||
overview.value = payload.overview || null;
|
||||
} catch (error) {
|
||||
department.value = null;
|
||||
overview.value = null;
|
||||
errorMessage.value =
|
||||
error?.response?.data?.message || error?.message || t("superuser_dashboard.department_overview.errors.load");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHardware() {
|
||||
if (!hasDepartmentId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
hardwareLoading.value = true;
|
||||
hardwareWorkspace.value = null;
|
||||
|
||||
try {
|
||||
const response = await getEdgeGatewayDepartmentWorkspace(departmentId.value);
|
||||
hardwareWorkspace.value = response?.data?.data || null;
|
||||
} catch (_error) {
|
||||
hardwareWorkspace.value = null;
|
||||
} finally {
|
||||
hardwareLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openQuickLink(path) {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.departmentId, route.query.date, route.query.date_to],
|
||||
() => {
|
||||
const normalized = normalizeRange(route.query.date, route.query.date_to || route.query.date);
|
||||
dateFrom.value = normalized.from;
|
||||
dateTo.value = normalized.to;
|
||||
loadOverview();
|
||||
loadHardware();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
// Get the department from the route
|
||||
const router = useRouter()
|
||||
setDepartment(router.currentRoute.value.params.departmentId);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<DepartmentSubPageWrapper>
|
||||
<template #title>
|
||||
<PageTitle :title="pageTitle" :subtitle="pageSubtitle" />
|
||||
<PageTitle title="Department" subtitle="Department data" />
|
||||
</template>
|
||||
|
||||
<section class="department-overview" data-testid="superuser-department-overview">
|
||||
<div class="overview-toolbar">
|
||||
<div class="overview-period">
|
||||
<label class="date-field">
|
||||
<span>{{ t("superuser_dashboard.department_overview.date_from") }}</span>
|
||||
<input
|
||||
v-model="dateFrom"
|
||||
class="input"
|
||||
data-testid="department-overview-date-from"
|
||||
type="date"
|
||||
@change="applyDateInputs"
|
||||
/>
|
||||
</label>
|
||||
<label class="date-field">
|
||||
<span>{{ t("superuser_dashboard.department_overview.date_to") }}</span>
|
||||
<input
|
||||
v-model="dateTo"
|
||||
class="input"
|
||||
data-testid="department-overview-date-to"
|
||||
type="date"
|
||||
@change="applyDateInputs"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="department-overview-preset-today"
|
||||
type="button"
|
||||
@click="setToday"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-calendar-day" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.presets.today") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="department-overview-preset-week"
|
||||
type="button"
|
||||
@click="setLastSevenDays"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-calendar-week" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.presets.last_seven_days") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="period-chip" data-testid="department-overview-period">
|
||||
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
|
||||
<span>{{ periodLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-overview-error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !overview" class="overview-loading" data-testid="department-overview-loading">
|
||||
<span class="icon"><i class="fa-solid fa-spinner fa-spin" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.loading") }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="kpi-grid">
|
||||
<article
|
||||
v-for="metric in metricDefinitions"
|
||||
:key="metric.key"
|
||||
class="kpi-card"
|
||||
:data-testid="`department-overview-kpi-${metric.key}`"
|
||||
>
|
||||
<div class="kpi-icon">
|
||||
<i :class="['fa-solid', metric.icon]" />
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">{{ metric.label }}</span>
|
||||
<strong class="kpi-value">{{ metricValue(metric) }}</strong>
|
||||
<span v-if="metricSecondary(metric)" class="kpi-secondary">{{ metricSecondary(metric) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-main">
|
||||
<section class="overview-panel" data-testid="department-overview-products">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.products.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.products.subtitle") }}</p>
|
||||
</div>
|
||||
<span class="count-badge">{{ productTiles.length }}</span>
|
||||
</div>
|
||||
<div v-if="productTiles.length" class="product-list">
|
||||
<div v-for="product in productTiles" :key="product.slug || product.product_id" class="product-row">
|
||||
<div class="product-title">
|
||||
<strong>{{ product.title }}</strong>
|
||||
<span>{{ product.slug }}</span>
|
||||
</div>
|
||||
<div class="product-stat">
|
||||
<strong>{{ formatNumber(product.value) }}</strong>
|
||||
<span>
|
||||
{{
|
||||
t("superuser_dashboard.department_overview.out_of", {
|
||||
total: formatNumber(product.out_of),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
{{ t("superuser_dashboard.department_overview.products.empty") }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-panel" data-testid="department-overview-profile">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.profile.title") }}</h2>
|
||||
<p>{{ department?.description || t("superuser_dashboard.department_overview.profile.no_description") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="profile-list">
|
||||
<div v-for="row in departmentProfileRows" :key="row.label" class="profile-row">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="hasHardwareSummary" class="overview-panel" data-testid="department-overview-hardware">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.hardware.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.hardware.subtitle") }}</p>
|
||||
</div>
|
||||
<span v-if="hardwareLoading" class="icon"><i class="fa-solid fa-spinner fa-spin" /></span>
|
||||
</div>
|
||||
<div class="hardware-grid">
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.gateways") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.onlineGateways) }} / {{ formatNumber(hardwareSummary.gateways) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.lanes") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.lanes) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.gates") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.gates) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.relays") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.relays) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.scanners") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.scanners) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat" :class="{ 'has-issues': hardwareSummary.issues > 0 }">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.issues") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.issues) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-panel quick-links-panel" data-testid="department-overview-quick-links">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.quick_links.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.quick_links.subtitle") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-link-grid">
|
||||
<button
|
||||
v-for="link in quickLinks"
|
||||
:key="link.path"
|
||||
class="quick-link"
|
||||
type="button"
|
||||
@click="openQuickLink(link.path)"
|
||||
>
|
||||
<span class="icon"><i :class="['fa-solid', link.icon]" /></span>
|
||||
<span>{{ link.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<div>
|
||||
Department stuff
|
||||
<code>{{ departmentId }}</code>
|
||||
<code>{{ department.id }}</code>
|
||||
<code>{{ department.name }}</code>
|
||||
<code>{{ department.description }}</code>
|
||||
<code>{{ department.created_at }}</code>
|
||||
<code>{{ department.updated_at }}</code>
|
||||
</div>
|
||||
</DepartmentSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.department-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.overview-toolbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.overview-period {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.date-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.date-field span,
|
||||
.kpi-label,
|
||||
.kpi-secondary,
|
||||
.panel-heading-row p,
|
||||
.product-title span,
|
||||
.product-stat span,
|
||||
.hardware-stat span,
|
||||
.profile-row dt {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.period-chip {
|
||||
align-items: center;
|
||||
background: #eef2ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 8px;
|
||||
color: #3730a3;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
gap: 0.35rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-loading,
|
||||
.empty-state {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
min-height: 5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
.kpi-card,
|
||||
.overview-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-height: 6rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
align-items: center;
|
||||
background: #ecfeff;
|
||||
border-radius: 8px;
|
||||
color: #0f766e;
|
||||
display: inline-flex;
|
||||
height: 2.5rem;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.kpi-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
color: #0f172a;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(18rem, 0.75fr);
|
||||
}
|
||||
|
||||
.overview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-heading-row {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-heading-row h2 {
|
||||
color: #0f172a;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-heading-row p {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
align-items: center;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 999px;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
font-weight: 800;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
|
||||
.product-list,
|
||||
.profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-row,
|
||||
.profile-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid #edf2f7;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
min-height: 3.25rem;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-title strong {
|
||||
color: #1e293b;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.product-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 5.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.product-stat strong,
|
||||
.hardware-stat strong,
|
||||
.profile-row dd {
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-row dt,
|
||||
.profile-row dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-row dd {
|
||||
max-width: 58%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hardware-grid,
|
||||
.quick-link-grid {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hardware-stat {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 4.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.hardware-stat.has-issues {
|
||||
background: #fff7ed;
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
|
||||
.quick-links-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.quick-link {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 8px;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: 800;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
min-height: 3rem;
|
||||
padding: 0 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quick-link:hover,
|
||||
.quick-link:focus {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.overview-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.period-chip {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.date-field,
|
||||
.overview-period .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hardware-grid,
|
||||
.quick-link-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-row,
|
||||
.profile-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.product-stat,
|
||||
.profile-row dd {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
+24
-55
@@ -1,60 +1,31 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRouter} from "vue-router";
|
||||
import { ref } from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
// Get the user from the route
|
||||
const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId))
|
||||
|
||||
const departmentId = computed(() => String(route.params.departmentId || ""));
|
||||
const departmentPath = computed(() => `/superuser/departments/${encodeURIComponent(departmentId.value)}`);
|
||||
const tabs = [
|
||||
{ name: 'Overblik', path: '/superuser/departments/' + departmentId.value },
|
||||
{ name: 'Moduler', path: '/superuser/departments/' + departmentId.value + '/modules' },
|
||||
{ name: 'Profil & Branding', path: '/superuser/departments/' + departmentId.value + '/branding' },
|
||||
{ name: 'Gateways', path: '/superuser/departments/' + departmentId.value + '/gateways' },
|
||||
{ name: 'Stripe', path: '/superuser/departments/' + departmentId.value + '/stripe/terminals/readers' },
|
||||
{ name: 'Priser', path: '/superuser/departments/' + departmentId.value + '/pricing' },
|
||||
{ name: SessionUser.objects.categories.meta.title, path: '/superuser/departments/' + departmentId.value + '/categories' },
|
||||
];
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.overview"),
|
||||
path: departmentPath.value,
|
||||
active: (path) => path === departmentPath.value,
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.modules"),
|
||||
path: `${departmentPath.value}/modules`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/modules`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.branding"),
|
||||
path: `${departmentPath.value}/branding`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/branding`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.gateways"),
|
||||
path: `${departmentPath.value}/gateways`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/gateways`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.stripe"),
|
||||
path: `${departmentPath.value}/stripe/terminals/readers`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/stripe`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.pricing"),
|
||||
path: `${departmentPath.value}/pricing`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/pricing`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.categories"),
|
||||
path: `${departmentPath.value}/categories`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/categories`),
|
||||
},
|
||||
]);
|
||||
// Get the current path
|
||||
const currentPath = ref(router.currentRoute.value.path);
|
||||
|
||||
const activeTab = computed(() => tabs.value.findIndex((tab) => tab.active(route.path)));
|
||||
// Get the index of the active tab
|
||||
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
|
||||
|
||||
// Change the tab
|
||||
const changeTab = (index) => {
|
||||
const tab = tabs.value[index];
|
||||
if (tab) {
|
||||
router.push(tab.path);
|
||||
}
|
||||
router.push(tabs[index].path);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -62,15 +33,13 @@ const changeTab = (index) => {
|
||||
<div>
|
||||
<div class="tabs is-right">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.path"
|
||||
:class="{ 'is-active': activeTab === index }"
|
||||
@click="changeTab(index)"
|
||||
>
|
||||
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
|
||||
<a>{{ tab.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
@@ -1933,6 +1933,111 @@ test.describe("Invoicing period tab", () => {
|
||||
await expect(page).toHaveURL(/activeTab=period/);
|
||||
});
|
||||
|
||||
test("@smoke period view warns and splits selected multi-month invoice collections", async ({ page }) => {
|
||||
const splitRequests = [];
|
||||
const economicInvoiceRequests = [];
|
||||
await openPeriodView(page, {
|
||||
payloadFactory: () => ({
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
id: 31,
|
||||
customer_number: 4301,
|
||||
customer_name: "Multi Month Fleet",
|
||||
requires_action: true,
|
||||
transactions: [
|
||||
{
|
||||
id: 8801,
|
||||
date: "2026-03-28T10:00:00.000Z",
|
||||
amount: 120,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 88001,
|
||||
},
|
||||
{
|
||||
id: 8802,
|
||||
date: "2026-04-02T10:00:00.000Z",
|
||||
amount: 180,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 88001,
|
||||
},
|
||||
],
|
||||
queue: {
|
||||
has_active_job: false,
|
||||
statuses: [],
|
||||
invoice_collection_ids: [],
|
||||
is_action_blocked: false,
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
invoice_per_order: [],
|
||||
fixed_pricing: [],
|
||||
tank_cleaning: [],
|
||||
special_arrangements: [],
|
||||
vehicle_subscriptions: [],
|
||||
possible_duplicates: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await page.route("**/collected-invoices/split-by-month**", async (route) => {
|
||||
if (
|
||||
route.request().method() !== "POST" ||
|
||||
!matchesApiPath(route.request().url(), "/collected-invoices/split-by-month")
|
||||
) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(route.request().postData() || "{}");
|
||||
splitRequests.push(payload);
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
preview: false,
|
||||
processed_count: 1,
|
||||
changed_count: 1,
|
||||
skipped_count: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/collected-invoices/economic**", async (route) => {
|
||||
if (
|
||||
route.request().method() === "POST" &&
|
||||
matchesApiPath(route.request().url(), "/collected-invoices/economic")
|
||||
) {
|
||||
economicInvoiceRequests.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill(json({ data: {} }));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.getByTestId("invoicing-period-view-selector-all").click();
|
||||
await expect(page.getByTestId("invoicing-period-customer-4301")).toBeVisible();
|
||||
await page.getByTestId("invoicing-period-customer-invoice-4301").click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Orders from multiple months|Ordrer fra flere måneder/i })
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /Split by month|Opdel efter måned/i }).click();
|
||||
|
||||
await expect.poll(() => splitRequests.length).toBe(1);
|
||||
expect(splitRequests[0]).toEqual({
|
||||
dateFrom: "2026-03-28",
|
||||
dateTo: "2026-04-02",
|
||||
invoice_collection_ids: [88001],
|
||||
preview: false,
|
||||
});
|
||||
expect(economicInvoiceRequests).toEqual([]);
|
||||
await expect(page.getByText(/Monthly split completed|Månedsopdeling fuldført/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("@smoke period view invoices multiple customers with page refresh and independent loading", async ({ page }) => {
|
||||
const token = "superuser-period-parallel-token";
|
||||
const periodRequests = [];
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
const json = (body, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function installOverviewRoutes(page) {
|
||||
await page.route(apiPathPattern("/superuser/departments/1/overview"), async (route) => {
|
||||
const parsedUrl = new URL(route.request().url());
|
||||
expect(parsedUrl.searchParams.get("date")).toBe("2026-07-06");
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
department: {
|
||||
id: 1,
|
||||
name: "Esbjerg",
|
||||
description: "Skagerrakvej 15",
|
||||
economic_department_id: 42,
|
||||
branding: 14,
|
||||
created_at: "2026-01-01 00:00:00",
|
||||
updated_at: "2026-07-06 09:30:00",
|
||||
},
|
||||
overview: {
|
||||
department_ids: [1],
|
||||
date: "2026-07-06",
|
||||
date_to: "2026-07-06",
|
||||
metrics: {
|
||||
bookings: { state: "ready", value: 3, out_of: 4 },
|
||||
complaints: { state: "ready", value: 1 },
|
||||
night_washes: { state: "ready", value: 2 },
|
||||
revenue: { state: "ready", value: 1234 },
|
||||
washes: { state: "ready", value: 11 },
|
||||
products_sold: { state: "ready", value: 18 },
|
||||
transactions: { state: "ready", value: 9 },
|
||||
water_usage: { state: "ready", value: 250 },
|
||||
overtime: { state: "ready", value: 1.5 },
|
||||
},
|
||||
products: [
|
||||
{
|
||||
product_id: 24,
|
||||
slug: "spot-free-lastbil",
|
||||
title: "Spot Free",
|
||||
state: "ready",
|
||||
value: 4,
|
||||
out_of: 11,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(apiPathPattern("/modules/edge-gateways/workspace/departments/1"), async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
gateways: [
|
||||
{ id: 1, status: "ONLINE" },
|
||||
{ id: 2, status: "OFFLINE" },
|
||||
],
|
||||
lanes: [{ id: 1 }, { id: 2 }],
|
||||
gates: [{ id: 1 }],
|
||||
relays: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
scanners: [{ id: 1 }],
|
||||
issues: [{ key: "gateway-offline" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("superuser department overview", () => {
|
||||
test("renders the operational overview for a selected department", async ({ page }) => {
|
||||
await seedAuthenticatedState(page, "superuser-department-overview-token");
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
});
|
||||
await installOverviewRoutes(page);
|
||||
|
||||
await page.goto("/superuser/departments/1?date=2026-07-06", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("superuser-department-overview")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Esbjerg" })).toBeVisible();
|
||||
await expect(page.getByTestId("department-overview-kpi-revenue")).toContainText("1.234");
|
||||
await expect(page.getByTestId("department-overview-kpi-bookings")).toContainText("of 4");
|
||||
await expect(page.getByTestId("department-overview-products")).toContainText("Spot Free");
|
||||
await expect(page.getByTestId("department-overview-hardware")).toContainText("1 / 2");
|
||||
await expect(page.getByTestId("department-overview-quick-links")).toContainText("Gateways");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("sweetalert2", () => ({
|
||||
default: {
|
||||
fire: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import Swal from "sweetalert2";
|
||||
import {
|
||||
buildMultiMonthInvoiceContext,
|
||||
MULTI_MONTH_INVOICE_ACTION,
|
||||
promptMultiMonthInvoiceWarning,
|
||||
shouldWarnAboutMultiMonthInvoice,
|
||||
} from "@/services/invoiceMonthSplitWarning.js";
|
||||
|
||||
describe("invoice month split warning", () => {
|
||||
beforeEach(() => {
|
||||
Swal.fire.mockReset();
|
||||
});
|
||||
|
||||
it("builds a scoped multi-month context without timezone shifting date strings", () => {
|
||||
const context = buildMultiMonthInvoiceContext([
|
||||
{ id: 1, created_at: "2026-03-31 23:30:00", invoice_collection_id: 501 },
|
||||
{ id: 2, created_at: "2026-04-01T00:30:00.000Z", invoice_collection_id: "501" },
|
||||
{ id: 3, created_at: "2026-04-03 09:00:00", invoice_collection_id: 502 },
|
||||
{ id: 4, created_at: "invalid", invoice_collection_id: null },
|
||||
]);
|
||||
|
||||
expect(context).toEqual({
|
||||
months: ["2026-03", "2026-04"],
|
||||
dateFrom: "2026-03-31",
|
||||
dateTo: "2026-04-03",
|
||||
invoiceCollectionIds: [501, 502],
|
||||
});
|
||||
expect(shouldWarnAboutMultiMonthInvoice(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("continues without a modal for single-month selections", async () => {
|
||||
const action = await promptMultiMonthInvoiceWarning({
|
||||
context: buildMultiMonthInvoiceContext([
|
||||
{ created_at: "2026-04-01 10:00:00", invoice_collection_id: 501 },
|
||||
{ created_at: "2026-04-02 10:00:00", invoice_collection_id: 501 },
|
||||
]),
|
||||
splitByMonth: vi.fn(),
|
||||
});
|
||||
|
||||
expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CONTINUE);
|
||||
expect(Swal.fire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("splits selected invoice collections when the warning is confirmed", async () => {
|
||||
const splitByMonth = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
processed_count: 2,
|
||||
changed_count: 1,
|
||||
skipped_count: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
Swal.fire.mockResolvedValueOnce({ isConfirmed: true }).mockResolvedValueOnce({ isConfirmed: true });
|
||||
|
||||
const action = await promptMultiMonthInvoiceWarning({
|
||||
context: buildMultiMonthInvoiceContext([
|
||||
{ created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 },
|
||||
{ created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 },
|
||||
]),
|
||||
splitByMonth,
|
||||
});
|
||||
|
||||
expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.SPLIT);
|
||||
expect(splitByMonth).toHaveBeenCalledWith("2026-03-20", "2026-04-02", {
|
||||
invoiceCollectionIds: [7001],
|
||||
preview: false,
|
||||
});
|
||||
expect(Swal.fire).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
icon: "warning",
|
||||
showDenyButton: true,
|
||||
})
|
||||
);
|
||||
expect(Swal.fire).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
icon: "success",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("continues invoicing together when the warning deny button is selected", async () => {
|
||||
const splitByMonth = vi.fn();
|
||||
Swal.fire.mockResolvedValueOnce({ isDenied: true });
|
||||
|
||||
const action = await promptMultiMonthInvoiceWarning({
|
||||
context: buildMultiMonthInvoiceContext([
|
||||
{ created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 },
|
||||
{ created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 },
|
||||
]),
|
||||
splitByMonth,
|
||||
});
|
||||
|
||||
expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CONTINUE);
|
||||
expect(splitByMonth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels invoicing when the warning is dismissed", async () => {
|
||||
const splitByMonth = vi.fn();
|
||||
Swal.fire.mockResolvedValueOnce({ isDismissed: true });
|
||||
|
||||
const action = await promptMultiMonthInvoiceWarning({
|
||||
context: buildMultiMonthInvoiceContext([
|
||||
{ created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 },
|
||||
{ created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 },
|
||||
]),
|
||||
splitByMonth,
|
||||
});
|
||||
|
||||
expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CANCEL);
|
||||
expect(splitByMonth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key) => key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("sweetalert2", () => ({
|
||||
default: {
|
||||
fire: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
objects: {
|
||||
global: {
|
||||
language: {
|
||||
hide_content: "Hide",
|
||||
show_content: "Show",
|
||||
status: "Status",
|
||||
completed: "Completed",
|
||||
not_completed: "Not completed",
|
||||
},
|
||||
},
|
||||
orders: {
|
||||
columns: {
|
||||
id: { label: "ID", visible: true },
|
||||
created_at: { label: "Created", visible: true },
|
||||
},
|
||||
},
|
||||
collectedOrderInvoices: {
|
||||
functions: {
|
||||
split_by_month: vi.fn(),
|
||||
economic: {
|
||||
invoice: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
vehicles: {
|
||||
columns: {
|
||||
wash_subscription: {
|
||||
label: "Subscription",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
functions: {
|
||||
currency: {
|
||||
toLocal: (value) => String(value),
|
||||
},
|
||||
parseErrorMessage: (error) => error?.message ?? String(error),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/views/dashboards/superUserDashboard/user/displays/other/UserOtherSpecialArrangement.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/views/dashboards/superUserDashboard/user/displays/other/UserOtherVaskeabonnement.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/order/orderItemsTable.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/superuser/tables/OrderContentTable.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
import Swal from "sweetalert2";
|
||||
import InvoiceOrderTable from "@/components/displays/superuser/tables/InvoiceOrderTable.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const mountTable = (orders) =>
|
||||
mount(InvoiceOrderTable, {
|
||||
props: {
|
||||
orders,
|
||||
options: {},
|
||||
columns: {},
|
||||
user_id: 42,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) =>
|
||||
({
|
||||
"global.invoice_now": "Invoice now",
|
||||
"global.unselect": "Unselect",
|
||||
"common.all": "All",
|
||||
"common.select": "Select",
|
||||
}[key] ?? key),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("InvoiceOrderTable multi-month warning", () => {
|
||||
beforeEach(() => {
|
||||
Swal.fire.mockReset();
|
||||
SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockReset();
|
||||
SessionUser.objects.collectedOrderInvoices.functions.economic.invoice.mockReset();
|
||||
SessionUser.objects.collectedOrderInvoices.functions.economic.invoice.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("warns for selected invoice collections containing orders from multiple months before invoicing together", async () => {
|
||||
Swal.fire.mockResolvedValueOnce({ isDenied: true }).mockReturnValueOnce(new Promise(() => {}));
|
||||
const wrapper = mountTable([
|
||||
{
|
||||
id: 1,
|
||||
invoice_collection_id: 9001,
|
||||
created_at: "2026-03-15 10:00:00",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
invoice_collection_id: 9001,
|
||||
created_at: "2026-04-02 10:00:00",
|
||||
},
|
||||
]);
|
||||
|
||||
await wrapper.find("tbody input[type='checkbox']").trigger("click");
|
||||
await wrapper.get("[data-testid='invoice-order-table-invoice-button']").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(Swal.fire).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
icon: "warning",
|
||||
showDenyButton: true,
|
||||
})
|
||||
);
|
||||
expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled();
|
||||
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.invoice).toHaveBeenCalledWith(9001, 42);
|
||||
});
|
||||
|
||||
it("does not warn for selected invoice collections containing only one month", async () => {
|
||||
Swal.fire.mockReturnValueOnce(new Promise(() => {}));
|
||||
const wrapper = mountTable([
|
||||
{
|
||||
id: 1,
|
||||
invoice_collection_id: 9002,
|
||||
created_at: "2026-04-01 10:00:00",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
invoice_collection_id: 9002,
|
||||
created_at: "2026-04-02 10:00:00",
|
||||
},
|
||||
]);
|
||||
|
||||
await wrapper.find("tbody input[type='checkbox']").trigger("click");
|
||||
await wrapper.get("[data-testid='invoice-order-table-invoice-button']").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(Swal.fire).toHaveBeenCalledTimes(1);
|
||||
expect(Swal.fire).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Fakturaer oprettet",
|
||||
})
|
||||
);
|
||||
expect(SessionUser.objects.collectedOrderInvoices.functions.economic.invoice).toHaveBeenCalledWith(9002, 42);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { computed, nextTick } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
@@ -34,6 +34,12 @@ vi.mock("@/services/economicTransferQueue.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sweetalert2", () => ({
|
||||
default: {
|
||||
fire: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
objects: {
|
||||
@@ -62,6 +68,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
createVehicleSubscriptionInvoice: vi.fn(),
|
||||
add_fixed_pricing: vi.fn(),
|
||||
add_vehicle_subscriptions: vi.fn(),
|
||||
split_by_month: vi.fn(),
|
||||
},
|
||||
},
|
||||
vehicles: {
|
||||
@@ -76,6 +83,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
currency: {
|
||||
toLocal: (value) => String(value),
|
||||
},
|
||||
parseErrorMessage: (error) => error?.message ?? String(error),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -148,6 +156,7 @@ vi.mock(
|
||||
import InvoicingBillingPeriodViewAll from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import {
|
||||
periodPaging,
|
||||
resetPeriodPagingState,
|
||||
@@ -222,11 +231,23 @@ describe("Invoicing period queue state", () => {
|
||||
loadingCustomerNumbersRef.value = [];
|
||||
SessionUser.objects.orders.get.multiple.mockReset();
|
||||
SessionUser.objects.orders.get.multiple.mockResolvedValue([]);
|
||||
SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockReset();
|
||||
SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
processed_count: 1,
|
||||
changed_count: 1,
|
||||
skipped_count: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
invoiceQueue.addInvoiceCollectionsToQueue.mockReset();
|
||||
invoiceQueue.processInvoiceCollectionQueue.mockReset();
|
||||
invoiceQueue.markPeriodRefreshLoading.mockClear();
|
||||
invoiceQueue.finishPeriodRefresh.mockClear();
|
||||
invoiceQueue.isPeriodCustomerRefreshLoading.mockClear();
|
||||
Swal.fire.mockReset();
|
||||
Swal.fire.mockResolvedValue({ isDenied: true });
|
||||
resetPeriodPagingState();
|
||||
sharedVariablesRef.value = {
|
||||
types: {
|
||||
@@ -644,6 +665,168 @@ describe("Invoicing period queue state", () => {
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("splits instead of queueing when multi-month invoicing warning is confirmed", async () => {
|
||||
sharedVariablesRef.value = {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
id: 8,
|
||||
customer_number: 1008,
|
||||
customer_name: "Multi Month Customer",
|
||||
requires_action: true,
|
||||
queue: {
|
||||
has_active_job: false,
|
||||
statuses: [],
|
||||
invoice_collection_ids: [],
|
||||
is_action_blocked: false,
|
||||
},
|
||||
transactions: [
|
||||
{
|
||||
id: 8101,
|
||||
amount: 75,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 8100,
|
||||
date: "2026-03-28T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 8102,
|
||||
amount: 125,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 8100,
|
||||
date: "2026-04-02T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Swal.fire.mockResolvedValueOnce({ isConfirmed: true }).mockResolvedValueOnce({ isConfirmed: true });
|
||||
|
||||
const wrapper = mountView();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.get("[data-testid='invoicing-period-customer-invoice-1008']").trigger("click");
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).toHaveBeenCalledWith(
|
||||
"2026-03-28",
|
||||
"2026-04-02",
|
||||
{
|
||||
invoiceCollectionIds: [8100],
|
||||
preview: false,
|
||||
}
|
||||
);
|
||||
expect(invoiceQueue.addInvoiceCollectionsToQueue).not.toHaveBeenCalled();
|
||||
expect(invoiceQueue.processInvoiceCollectionQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues queueing together when multi-month invoicing warning is denied", async () => {
|
||||
sharedVariablesRef.value = {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
id: 9,
|
||||
customer_number: 1009,
|
||||
customer_name: "Invoice Together Customer",
|
||||
requires_action: true,
|
||||
queue: {
|
||||
has_active_job: false,
|
||||
statuses: [],
|
||||
invoice_collection_ids: [],
|
||||
is_action_blocked: false,
|
||||
},
|
||||
transactions: [
|
||||
{
|
||||
id: 8201,
|
||||
amount: 75,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 8200,
|
||||
date: "2026-03-28T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 8202,
|
||||
amount: 125,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 8200,
|
||||
date: "2026-04-02T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Swal.fire.mockResolvedValueOnce({ isDenied: true });
|
||||
|
||||
const wrapper = mountView();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.get("[data-testid='invoicing-period-customer-invoice-1009']").trigger("click");
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled();
|
||||
expect(invoiceQueue.addInvoiceCollectionsToQueue).toHaveBeenCalledWith([8200], {
|
||||
customerNumber: 1009,
|
||||
});
|
||||
expect(invoiceQueue.processInvoiceCollectionQueue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not queue when multi-month invoicing warning is dismissed", async () => {
|
||||
sharedVariablesRef.value = {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
id: 10,
|
||||
customer_number: 1010,
|
||||
customer_name: "Cancel Multi Month Customer",
|
||||
requires_action: true,
|
||||
queue: {
|
||||
has_active_job: false,
|
||||
statuses: [],
|
||||
invoice_collection_ids: [],
|
||||
is_action_blocked: false,
|
||||
},
|
||||
transactions: [
|
||||
{
|
||||
id: 8301,
|
||||
amount: 75,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 8300,
|
||||
date: "2026-03-28T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 8302,
|
||||
amount: 125,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 8300,
|
||||
date: "2026-04-02T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Swal.fire.mockResolvedValueOnce({ isDismissed: true });
|
||||
|
||||
const wrapper = mountView();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.get("[data-testid='invoicing-period-customer-invoice-1010']").trigger("click");
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled();
|
||||
expect(invoiceQueue.addInvoiceCollectionsToQueue).not.toHaveBeenCalled();
|
||||
expect(invoiceQueue.processInvoiceCollectionQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies invoice-now loading only to the affected customer", async () => {
|
||||
sharedVariablesRef.value = {
|
||||
types: {
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { nextTick } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: {
|
||||
params: { departmentId: "1" },
|
||||
query: { date: "2026-07-06", date_to: "2026-07-06" },
|
||||
path: "/superuser/departments/1",
|
||||
},
|
||||
router: {
|
||||
replace: vi.fn((nextRoute) => {
|
||||
mocks.route.query = nextRoute.query || {};
|
||||
return Promise.resolve();
|
||||
}),
|
||||
push: vi.fn(),
|
||||
},
|
||||
getSuperuserDepartmentOverview: vi.fn(),
|
||||
getEdgeGatewayDepartmentWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: () => mocks.route,
|
||||
useRouter: () => mocks.router,
|
||||
}));
|
||||
|
||||
vi.mock("@/services/superuserDepartmentOverview.js", () => ({
|
||||
getSuperuserDepartmentOverview: mocks.getSuperuserDepartmentOverview,
|
||||
}));
|
||||
|
||||
vi.mock("@/services/edgeGateways.js", () => ({
|
||||
getEdgeGatewayDepartmentWorkspace: mocks.getEdgeGatewayDepartmentWorkspace,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
canAccessSuperUser: () => true,
|
||||
functions: {
|
||||
currency: {
|
||||
toLocal: (value) => `${Number(value).toLocaleString("da-DK")} kr.`,
|
||||
},
|
||||
date: {
|
||||
toLocal: (value) => `local:${value}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/global/PageTitle.vue", () => ({
|
||||
default: {
|
||||
props: ["title", "subtitle"],
|
||||
template: "<header><h1>{{ title }}</h1><p>{{ subtitle }}</p></header>",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({
|
||||
default: {
|
||||
template: "<div><slot /></div>",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue", () => ({
|
||||
default: {
|
||||
template: "<main><slot name='title' /><slot /></main>",
|
||||
},
|
||||
}));
|
||||
|
||||
import DepartmentOverview from "@/views/dashboards/superUserDashboard/department/Department.vue";
|
||||
import DepartmentNavigation from "@/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue";
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
superuser_dashboard: {
|
||||
department_navigation: {
|
||||
overview: "Overview",
|
||||
modules: "Modules",
|
||||
branding: "Profile & Branding",
|
||||
gateways: "Gateways",
|
||||
stripe: "Stripe",
|
||||
pricing: "Pricing",
|
||||
categories: "Categories",
|
||||
},
|
||||
department_overview: {
|
||||
title: "Department overview",
|
||||
subtitle: "Operational overview",
|
||||
loading: "Loading department overview",
|
||||
date_from: "From",
|
||||
date_to: "To",
|
||||
range_label: "{from} to {to}",
|
||||
empty_value: "-",
|
||||
out_of: "of {total}",
|
||||
presets: {
|
||||
today: "Today",
|
||||
last_seven_days: "Last 7 days",
|
||||
},
|
||||
metrics: {
|
||||
bookings: "Bookings",
|
||||
complaints: "Complaints",
|
||||
night_washes: "Night washes",
|
||||
overtime: "Overtime",
|
||||
products_sold: "Products sold",
|
||||
revenue: "Revenue",
|
||||
transactions: "Transactions",
|
||||
washes: "Washes",
|
||||
water_usage: "Water",
|
||||
},
|
||||
units: {
|
||||
hours: "h",
|
||||
liters: "L",
|
||||
},
|
||||
products: {
|
||||
title: "Product mix",
|
||||
subtitle: "Tracked wash products",
|
||||
empty: "No product activity",
|
||||
},
|
||||
profile: {
|
||||
title: "Department profile",
|
||||
no_description: "No department description",
|
||||
department_id: "Department ID",
|
||||
economic_department_id: "Economic department",
|
||||
branding: "Branding",
|
||||
created_at: "Created",
|
||||
updated_at: "Updated",
|
||||
},
|
||||
hardware: {
|
||||
title: "Hardware readiness",
|
||||
subtitle: "Gateway-backed state",
|
||||
gateways: "Gateways online",
|
||||
lanes: "Lanes",
|
||||
gates: "Gates",
|
||||
relays: "Relays",
|
||||
scanners: "Scanners",
|
||||
issues: "Issues",
|
||||
},
|
||||
quick_links: {
|
||||
title: "Department tools",
|
||||
subtitle: "Open setup areas",
|
||||
modules: "Modules",
|
||||
branding: "Branding",
|
||||
gateways: "Gateways",
|
||||
stripe: "Stripe",
|
||||
pricing: "Pricing",
|
||||
categories: "Categories",
|
||||
},
|
||||
errors: {
|
||||
invalid_department: "A valid department is required",
|
||||
load: "Unable to load the department overview",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const overviewResponse = {
|
||||
data: {
|
||||
data: {
|
||||
department: {
|
||||
id: 1,
|
||||
name: "Esbjerg",
|
||||
description: "Skagerrakvej 15",
|
||||
economic_department_id: 42,
|
||||
branding: 14,
|
||||
created_at: "2026-01-01 00:00:00",
|
||||
updated_at: "2026-07-06 09:30:00",
|
||||
},
|
||||
overview: {
|
||||
department_ids: [1],
|
||||
date: "2026-07-06",
|
||||
date_to: "2026-07-06",
|
||||
metrics: {
|
||||
bookings: { state: "ready", value: 3, out_of: 4 },
|
||||
complaints: { state: "ready", value: 1 },
|
||||
night_washes: { state: "ready", value: 2 },
|
||||
revenue: { state: "ready", value: 1234 },
|
||||
washes: { state: "ready", value: 11 },
|
||||
products_sold: { state: "ready", value: 18 },
|
||||
transactions: { state: "ready", value: 9 },
|
||||
water_usage: { state: "ready", value: 250 },
|
||||
overtime: { state: "ready", value: 1.5 },
|
||||
},
|
||||
products: [
|
||||
{
|
||||
product_id: 24,
|
||||
slug: "spot-free-lastbil",
|
||||
title: "Spot Free",
|
||||
state: "ready",
|
||||
value: 4,
|
||||
out_of: 11,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hardwareResponse = {
|
||||
data: {
|
||||
data: {
|
||||
gateways: [
|
||||
{ id: 1, status: "ONLINE" },
|
||||
{ id: 2, status: "OFFLINE" },
|
||||
],
|
||||
lanes: [{ id: 1 }, { id: 2 }],
|
||||
gates: [{ id: 1 }],
|
||||
relays: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
scanners: [{ id: 1 }],
|
||||
issues: [{ key: "gateway-offline" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const flushRendering = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.route.params = { departmentId: "1" };
|
||||
mocks.route.query = { date: "2026-07-06", date_to: "2026-07-06" };
|
||||
mocks.route.path = "/superuser/departments/1";
|
||||
mocks.router.replace.mockClear();
|
||||
mocks.router.push.mockClear();
|
||||
mocks.getSuperuserDepartmentOverview.mockReset();
|
||||
mocks.getEdgeGatewayDepartmentWorkspace.mockReset();
|
||||
mocks.getSuperuserDepartmentOverview.mockResolvedValue(overviewResponse);
|
||||
mocks.getEdgeGatewayDepartmentWorkspace.mockResolvedValue(hardwareResponse);
|
||||
});
|
||||
|
||||
describe("Superuser department overview", () => {
|
||||
it("loads the superuser overview endpoint and renders operations data", async () => {
|
||||
const wrapper = mountWithApp(DepartmentOverview, {
|
||||
messages,
|
||||
});
|
||||
|
||||
await flushRendering();
|
||||
|
||||
expect(mocks.getSuperuserDepartmentOverview).toHaveBeenCalledWith(1, {
|
||||
date: "2026-07-06",
|
||||
dateTo: "2026-07-06",
|
||||
});
|
||||
expect(mocks.getEdgeGatewayDepartmentWorkspace).toHaveBeenCalledWith(1);
|
||||
expect(wrapper.find("h1").text()).toBe("Esbjerg");
|
||||
expect(wrapper.get('[data-testid="department-overview-kpi-revenue"]').text()).toContain("1.234 kr.");
|
||||
expect(wrapper.get('[data-testid="department-overview-kpi-bookings"]').text()).toContain("of 4");
|
||||
expect(wrapper.get('[data-testid="department-overview-products"]').text()).toContain("Spot Free");
|
||||
expect(wrapper.get('[data-testid="department-overview-profile"]').text()).toContain("Economic department");
|
||||
expect(wrapper.get('[data-testid="department-overview-hardware"]').text()).toContain("1 / 2");
|
||||
});
|
||||
|
||||
it("keeps the overview visible when the optional hardware summary is unavailable", async () => {
|
||||
mocks.getEdgeGatewayDepartmentWorkspace.mockRejectedValue(new Error("Forbidden"));
|
||||
|
||||
const wrapper = mountWithApp(DepartmentOverview, {
|
||||
messages,
|
||||
});
|
||||
|
||||
await flushRendering();
|
||||
|
||||
expect(wrapper.get('[data-testid="department-overview-kpi-revenue"]').text()).toContain("1.234 kr.");
|
||||
expect(wrapper.find('[data-testid="department-overview-hardware"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses translated reactive department tabs and pushes the selected route", async () => {
|
||||
mocks.route.path = "/superuser/departments/1/gateways";
|
||||
|
||||
const wrapper = mountWithApp(DepartmentNavigation, { messages });
|
||||
const activeTab = wrapper.find(".tabs li.is-active");
|
||||
|
||||
expect(activeTab.text()).toBe("Gateways");
|
||||
|
||||
const tabs = wrapper.findAll(".tabs li");
|
||||
await tabs.find((tab) => tab.text() === "Pricing").trigger("click");
|
||||
|
||||
expect(mocks.router.push).toHaveBeenCalledWith("/superuser/departments/1/pricing");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user