Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard 06e1552a47 Add superuser vehicle creation flow 2026-07-06 13:27:48 +02:00
135 changed files with 755 additions and 5598 deletions
+3 -15
View File
@@ -112,8 +112,6 @@ jobs:
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_VIDEO_MODE: on-first-retry
steps:
- name: Repair self-hosted workspace permissions
shell: bash
@@ -142,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
@@ -155,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
@@ -189,9 +181,8 @@ jobs:
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
@@ -232,8 +223,6 @@ jobs:
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_SUITE="$MATRIX_SUITE" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
@@ -368,9 +357,8 @@ jobs:
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
+2 -55
View File
@@ -7,18 +7,14 @@ export const fallbackChangePatterns = [
/^vite\.config\.js$/u,
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
/^scripts\/run-playwright-(?:ci-parallel|batched-chromium)\.mjs$/u,
/^scripts\/run-playwright-(?:pr|ci-parallel|batched-chromium)\.mjs$/u,
/^tests\/e2e\/(?:support|fixtures)\//u,
];
export const sourceMappings = [
{
name: "auth",
patterns: [
/^src\/(?:views|components|middleware)\/.*auth/iu,
/^src\/views\/auth\//u,
/^src\/components\/session\/(?!token\/SessionUser\/Objects\/)/u,
],
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
projects: chromiumProjects,
},
@@ -28,12 +24,6 @@ export const sourceMappings = [
specs: ["tests/e2e/navigation.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "backoffice",
patterns: [/^src\/views\/backoffice\//u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-roles-permissions",
patterns: [
@@ -43,34 +33,6 @@ export const sourceMappings = [
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-dashboard",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/SuperUserDashboard(?:Navigation)?\.vue$/u,
/^src\/components\/displays\/superuser\/system\//u,
],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
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: "header-navigation",
patterns: [
/^src\/components\/viewport\/page\/headers\//u,
/^src\/components\/models\/navigation\/items\/NavigationMenuItemsGlobal\.vue$/u,
],
specs: ["tests/e2e/navigation.smoke.spec.js", "tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "booking",
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
@@ -108,12 +70,6 @@ export const sourceMappings = [
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
projects: chromiumProjects,
},
{
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "invoicing",
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
@@ -136,15 +92,6 @@ export const sourceMappings = [
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-department-pricing",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|SuperUserSelectedDepartmentObject)\.vue$/u,
/^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u,
],
specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"],
projects: ["chromium-desktop"],
},
{
name: "self-serve",
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
-2
View File
@@ -101,10 +101,8 @@ 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-department-pricing-custom-only.spec.ts",
"superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts",
"superuser-products-layout.spec.ts",
+4 -24
View File
@@ -261,8 +261,6 @@ function selectChangedTests(changedFiles) {
specProjects: new Map(),
mappedFiles: [],
unmappedFiles: [],
directSpecFiles: [],
skippedDirectSpecFiles: [],
fallback: false,
};
@@ -272,7 +270,8 @@ function selectChangedTests(changedFiles) {
const file = normalizePath(rawFile);
if (isE2eSpec(file)) {
selection.directSpecFiles.push(file);
addSpec(selection, file, selectedProjects);
selection.mappedFiles.push(file);
continue;
}
@@ -296,26 +295,13 @@ function selectChangedTests(changedFiles) {
selection.mappedFiles.push(file);
for (const mapping of matches) {
const mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects;
const projects = mappedProjects.filter((project) => selectedProjects.includes(project));
if (projects.length === 0) {
continue;
}
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
for (const spec of mapping.specs) {
addSpec(selection, spec, projects);
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
}
}
}
if (selection.specProjects.size === 0 && !selection.fallback) {
for (const file of selection.directSpecFiles) {
addSpec(selection, file, selectedProjects);
selection.mappedFiles.push(file);
}
} else {
selection.skippedDirectSpecFiles.push(...selection.directSpecFiles);
}
return selection;
}
@@ -384,12 +370,6 @@ async function runChangedSelection(selection) {
return 0;
}
if (selection.skippedDirectSpecFiles.length > 0) {
console.log(
`[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(", ")}`
);
}
for (const [index, group] of groups.entries()) {
for (const project of group.projects) {
const code = await runPlaywright({
@@ -5,7 +5,6 @@ import { useWindowSize } from "@vueuse/core";
import { BMessage } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
type DateRange = {
startDate: Date,
@@ -116,11 +115,23 @@ const availableYears = computed(() => (
));
const formatDateInputValue = (date: Date) => {
return formatLocalDateOnly(date);
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "";
}
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 parseDateInputValue = (value: string) => {
return parseLocalDateOnly(value);
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return new Date(value);
}
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
};
const isSameDateInputValue = (left: Date, right: Date) => (
@@ -817,10 +817,6 @@ const isDraftAssignmentModalOpen = computed(() => {
});
const canEditTransaction = (order) => {
if (props.isCustomerView) {
return SessionUser.canAccessCustomerFeature("orders", "edit");
}
return SessionUser.canAccessAdmin() || SessionUser.canAccessDepartment(order.department_id);
};
@@ -1263,7 +1259,7 @@ const formatCashierName = (order) => {
:hover-text="order.reference"
:parse-function="(value) => truncateOrderField(value, 10)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canEditTransaction(order)"
:permission-check-function="() => true"
/>
<EditableTableColumn
:object="order"
@@ -1282,7 +1278,7 @@ const formatCashierName = (order) => {
:hover-text="order.po"
:parse-function="(value) => truncateOrderField(value, 10)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canEditTransaction(order)"
:permission-check-function="() => canSeePoField() || SessionUser.canAccessAdmin()"
/>
<td>{{ order.created_at }}</td>
<td>{{ SessionUser.functions.currency.toLocal(order.total_net_amount) }}</td>
@@ -1778,7 +1774,7 @@ const formatCashierName = (order) => {
:hover-text="order.reference"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canEditTransaction(order)"
:permission-check-function="() => true"
/>
</small>
</div>
@@ -1822,7 +1818,7 @@ const formatCashierName = (order) => {
:hover-text="order.po"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canEditTransaction(order)"
:permission-check-function="() => canSeePoField() || SessionUser.canAccessAdmin()"
/>
</small>
</div>
@@ -1867,7 +1863,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_1"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canEditTransaction(order)"
:permission-check-function="() => true"
/>
</small>
</div>
@@ -1899,7 +1895,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_2"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canEditTransaction(order)"
:permission-check-function="() => true"
/>
</small>
</div>
@@ -1931,7 +1927,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_3"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canEditTransaction(order)"
:permission-check-function="() => true"
/>
</small>
</div>
@@ -30,7 +30,6 @@ import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
import {
@@ -378,8 +377,8 @@ const fetchDuplicateOrdersForContext = async (context) => {
try {
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${
todayLocalDateOnly()
},created_at-date_to:${todayLocalDateOnly()}`,
new Date().toISOString().split("T")[0]
},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
limit: 5,
});
@@ -22,7 +22,7 @@ import PosDepartmentStep2MobileVehicleSelection
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import PosDepartmentStepMobile2FloatingCart
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
import { isAddonRestricted, canBuyAdditionalServices, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue";
import { isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
label: {
@@ -46,7 +46,6 @@ const props = defineProps({
},
});
const canSelectAdditionalItems = computed(() => canBuyAdditionalServices());
const checked = ref(props.defaultChecked);
// Function to generate a summary from the last order
function generateSummary(order: PosOrder): string {
@@ -73,12 +72,6 @@ const displaySubtitle = computed(() => {
});
// Emit event on toggle
function onToggle(isOpen: boolean) {
if (isOpen && !canSelectAdditionalItems.value) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
return;
}
checked.value = isOpen;
// Open the additional item selection view if toggled open
if (isOpen) {
@@ -125,45 +118,17 @@ const getAvailableAdditionalItems = () => {
}));
}
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
const isAdditionalItemRestricted = (product: PosProduct) => {
if (!canSelectAdditionalItems.value) {
return true;
}
if (isProductRestricted(product)) {
return true;
}
return isAddonRestricted(convertProductToAddon(product));
}
watch(canSelectAdditionalItems, (canSelect) => {
if (!canSelect) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
}
});
// Computed property to filter out restricted additional items based on customer attributes
const filteredAdditionalItems = computed(() => {
// If additional services are restricted, return empty array
if (!canSelectAdditionalItems.value) {
if (!canBuyAdditionalServices()) {
return [];
}
// Filter out individually restricted addons
return availableAdditionalItems.value.filter((addon: Addon) => {
if (isAddonRestricted(addon)) {
return false;
}
if (addon.product && isProductRestricted(addon.product)) {
return false;
}
return true;
});
return availableAdditionalItems.value.filter((addon: Addon) => !isAddonRestricted(addon));
});
const onClickAddOtherProduct = () => {
if (!canSelectAdditionalItems.value) {
pos.views.additionalItemSelection.value = false;
return;
}
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
}
@@ -194,9 +159,7 @@ watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
}, { deep: true });
const onClickAddProduct = async (product: PosProduct) => {
if (isAdditionalItemRestricted(product)) {
return;
}
// If the product requires note, open note input.
pos.transactionItems.addAdditionalItem(product);
// If the view is fullscreen, close it after adding
//if (pos.views.additionalItemSelection.value) {
@@ -208,7 +171,7 @@ const onClickAddProduct = async (product: PosProduct) => {
<template>
<div data-testid="pos-mobile-additional-items">
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard :toggleable="canSelectAdditionalItems && pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
@toggle="onToggle"
:forceState="(pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0) ? true : (pos.views.additionalItemSelection.value)"
@@ -238,7 +201,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</div>
</template>
<!-- Footer -->
<template #footer v-if="canSelectAdditionalItems">
<template #footer>
<!-- Select other product button -->
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
<span class="icon">
@@ -249,7 +212,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</template>
</WhiteBoxCard>
<!-- Fullscreen view, when selecting other products -->
<template v-else-if="canSelectAdditionalItems">
<template v-else>
<div data-testid="pos-mobile-additional-items-selection">
<!-- Categories of products -->
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
@@ -12,7 +12,6 @@ import PosDepartmentStepMobileButtonNextStep from "@/components/displays/departm
import SessionUser from "@/components/session/token/SessionUser.vue";
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
/** Display variables */
@@ -127,7 +126,7 @@ const liveTransactions = ref(null);
const isLoading = ref(true);
const syncListTransactionHistory = async () => {
let dateToday = todayLocalDateOnly(); // Get today's date in YYYY-MM-DD format
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
isLoading.value = true;
// Fetch the list of orders created today for the current department
SessionUser.objects.orders.get
@@ -1,7 +1,6 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatLocalDateOnly } from '@/services/dateOnly.js';
type startEndDate = {
start: Date; // The start date of the range
end: Date; // The end date of the range
@@ -28,7 +27,7 @@ const datePresetFunctions = {
month: {
// Get the first day of the month based on the provided date first day at 00:00:01
firstDayOfMonth: (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); // Set to the first day of the month at 00:00:00
return new Date(date.getFullYear(), date.getMonth(), 2, 0, 0, 1, 0); // Set to the first day of the month at 00:00:01
},
// Get the last day of the month based on the provided date last day at 23:59:59
lastDayOfMonth: (date: Date): Date => {
@@ -117,7 +116,7 @@ export const datePresets = <datePreset[]>[
*/
const convertToISO = (date: Date): string => {
return formatLocalDateOnly(date); // Convert to YYYY-MM-DD format
return date.toISOString().split('T')[0]; // Convert to YYYY-MM-DD format
};
export const dateFunctions = {
@@ -151,4 +150,4 @@ export default defineComponent({
},
});
</script>
<template></template>
<template></template>
@@ -50,7 +50,6 @@ import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
@@ -64,7 +63,7 @@ const parseInitialDate = (value) => {
return new Date();
}
const parsed = parseLocalDateOnly(value);
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
};
@@ -130,7 +129,7 @@ const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
const parsedDate = (date) => {
return parseLocalDateOnly(date);
return new Date(date);
};
const reloadScheduled = ref(false);
@@ -149,14 +148,14 @@ const actions = {
from: {
select: (date) => {
dateFrom.value = parsedDate(date);
setFilter("StartTime-date_from", formatLocalDateOnly(date), false);
setFilter("StartTime-date_from", date.toISOString().split("T")[0], false);
scheduleReload();
}
},
to: {
select: (date) => {
dateTo.value = parsedDate(date);
setFilter("StartTime-date_to", formatLocalDateOnly(date), false);
setFilter("StartTime-date_to", date.toISOString().split("T")[0], false);
scheduleReload();
}
},
@@ -109,8 +109,8 @@ import OrdersTable from "@/components/displays/department/pos/orders/ordersTable
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
import {now} from "@vueuse/core";
import { useI18n } from 'vue-i18n'
import { formatLocalDateOnly, parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
@@ -176,8 +176,8 @@ if (props.autoLoad) {
const date_from = ref(null);
const date_to = ref(null);
const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {
const formattedStartDate = formatLocalDateOnly(newSelectionStartDate);
const formattedEndDate = formatLocalDateOnly(newSelectionToDate);
const formattedStartDate = newSelectionStartDate.toISOString().split("T")[0];
const formattedEndDate = newSelectionToDate.toISOString().split("T")[0];
date_from.value = formattedStartDate;
date_to.value = formattedEndDate;
setFilter("created_at-date_from", formattedStartDate, true);
@@ -361,7 +361,7 @@ const doesEndpointMatch = (matcher) => {
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:selection="{ startDate: parseLocalDateOnly(date_from || todayLocalDateOnly()), endDate: parseLocalDateOnly(date_to || todayLocalDateOnly()) }"/>
v-bind:selection="{ startDate: date_from ? new Date(date_from) : new Date(now()), endDate: date_to ? new Date(date_to) : new Date(now()) }"/>
</div>
</template>
<template #default>
@@ -24,16 +24,13 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import BookingsTable from "@/components/displays/user/bookings/bookingsTable.vue";
import {departments} from "@/components/pagination/departmentTabs.vue";
import {computed, ref, watch} from "vue";
import {ref, watch} from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n'
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
// Set the selected status to all
const selectedStatus = ref("*");
@@ -55,7 +52,7 @@ watch(() => getFilter("status"), (value) => {
// If the route starts with /user, set the endpoint to /user/bookings
if (router.currentRoute.value.path.startsWith("/user")) {
setEndpoint("/user/bookings", false);
setFilter("date", todayLocalDateOnly(), false);
setFilter("date", new Date().toISOString().split("T")[0], false);
setOrder("created_at", "desc");
}
// If the route starts with /superuser, set the endpoint to /bookings
@@ -84,7 +81,7 @@ loadList();
const showingToday = ref(true);
watch(() => getFilter("date"), (value) => {
showingToday.value = value === todayLocalDateOnly();
showingToday.value = value === new Date().toISOString().split("T")[0];
});
// If the screen is mobile, set the is small variable to true
@@ -150,17 +147,17 @@ const showNewOrderBookingsPortal = () => {
</div>
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
<!-- Only show the bookings for today (Switch, if the route is /user) -->
<div class="column is-narrow my-3" v-if="isUserRoute" :class="{ 'has-text-right': !isSmall }">
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
<label class="label">{{ isSmall ? t('common.today') : t('pagination.show_only_today') }}</label>
<div class="control">
<div class="field">
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? todayLocalDateOnly() : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? new Date().toISOString().split('T')[0] : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
<label for="today"></label>
</div>
</div>
</div>
<!-- Create a new booking, if the route is /user -->
<div class="column is-narrow my-3" v-if="isUserRoute && canCreateBooking" :class="{ 'has-text-right': !isSmall }">
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
<button
class="button is-link"
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
@@ -191,4 +188,4 @@ const showNewOrderBookingsPortal = () => {
<style scoped>
</style>
</style>
@@ -8,7 +8,6 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { Colors } from "@/ThemeConfig.vue";
import { useI18n } from "vue-i18n";
import { endOfLocalDate, startOfLocalDate, todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n();
/**
@@ -17,7 +16,6 @@ const { t } = useI18n();
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
/**
* Props
*/
@@ -54,9 +52,11 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
setFilter("datetime-date_from", null, false);
setFilter("datetime-date_to", null, false);
} else {
const startOfDay = new Date().setHours(0, 0, 0, 0);
const endOfDay = new Date().setHours(23, 59, 59, 999);
//setFilter('datetime', null, false);
setFilter("datetime-date_from", startOfLocalDate(val).toISOString(), false);
setFilter("datetime-date_to", endOfLocalDate(val).toISOString(), false);
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
}
if (autoLoadList) {
loadList();
@@ -95,7 +95,7 @@ onMounted(() => {
departmentFilter.value = value;
} else if (key === "only_today" && value === true) {
setFilterKey = false; // Since the only_today filter is handled separately
onOnlyTodayFilterChange({ target: { value: todayLocalDateOnly() } }, false);
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
}
if (setFilterKey) {
setFilter(key, value, false);
@@ -170,7 +170,7 @@ onMounted(() => {
<div class="select">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option value="*">{{ t("common.all") }}</option>
<option :value="todayLocalDateOnly()">{{ t("common.yes") }}</option>
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
</select>
</div>
</div>
@@ -193,7 +193,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange({
target: { value: event.target.checked ? todayLocalDateOnly() : '*' },
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
});
}
"
@@ -203,7 +203,7 @@ onMounted(() => {
<label for="today"></label>
</div>
</div>
<div class="order-bookings-pagination__new-booking-action" v-if="canCreateBooking">
<div class="order-bookings-pagination__new-booking-action">
<label class="label is-small order-bookings-pagination__desktop-spacer">&nbsp;</label>
<button
class="button is-link button-same-width"
@@ -226,7 +226,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange(
{ target: { value: event.target.checked ? todayLocalDateOnly() : '*' } },
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
false
);
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
@@ -8,7 +8,6 @@ import {
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue";
import { computed } from "vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import {Colors} from "@/ThemeConfig.vue";
@@ -21,8 +20,6 @@ const { t } = useI18n();
* Router
*/
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const canAddVehicle = computed(() => SessionUser.canAccessCustomerFeature("vehicles", "add"));
const props = defineProps({
customer_id: {
@@ -61,7 +58,7 @@ loadList();
</div>
<!-- Create a new vehicle, if the route is /user -->
<div class="vehicles-pagination__add-action"
v-if="isUserRoute && canAddVehicle">
v-if="router.currentRoute.value.path.startsWith('/user')">
<label class="label is-small">&nbsp;</label>
<button
class="button is-link button-same-width vehicles-pagination__add-button"
@@ -1,7 +1,6 @@
<script setup lang="ts">
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
import { computed } from "vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
import { ref, computed } from "vue";
const props = defineProps({
label: {
type: String,
@@ -20,7 +19,7 @@ defineExpose({
});
const formattedDate = computed(() => {
return formatLocalDateOnly(date.value);
return date.value.toISOString().split('T')[0];
});
// This component is used to display a date input in a pagination display item column.
@@ -37,7 +36,8 @@ const formattedDate = computed(() => {
class="input"
:value="formattedDate"
@input="(e) => {
const newDate = parseLocalDateOnly(e.target.value);
const newDate = new Date(e.target.value);
console.log('Selected date:', newDate);
emit('update:date', newDate);
}"
/>
@@ -47,4 +47,4 @@ const formattedDate = computed(() => {
<style scoped>
</style>
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
@@ -9,7 +10,7 @@ import PaginationDisplayTemplateButton
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
defineProps({
const props = defineProps({
startDate: {
type: Date,
required: true,
@@ -45,7 +46,6 @@ function updateStartDate(date: Date) {
// Update the end date model when the date is changed
function updateEndDate(date: Date) {
endDateModel.value = date;
emits('update:endDate', date);
}
</script>
@@ -79,4 +79,4 @@ function updateEndDate(date: Date) {
<style scoped>
</style>
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { useI18n } from "vue-i18n";
import {
@@ -16,7 +16,6 @@ import {
const { t, te, locale } = useI18n();
const MAX_GATEWAY_CARDS = 8;
const DEFAULT_DASHBOARD_TAB = "infrastructure";
const gatewayStatusPriority = Object.freeze({
OFFLINE: 0,
DEGRADED: 1,
@@ -78,14 +77,7 @@ const gatewayFleetUsage = ref(createEmptyGatewayFleetUsage());
const gatewaySectionSuppressed = ref(false);
const gatewayDepartments = ref({});
const gatewayDepartmentsLoaded = ref(false);
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
const dashboardTabKeys = computed(() => [
DEFAULT_DASHBOARD_TAB,
...(showGatewaySection.value ? ["gateways"] : []),
"modules",
"sessions",
]);
const gatewaySummaryCards = computed(() => [
{
id: "total",
@@ -195,16 +187,6 @@ const isStale = computed(() => {
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
});
watch(
() => [activeDashboardTab.value, dashboardTabKeys.value.join("|")],
() => {
if (!dashboardTabKeys.value.includes(activeDashboardTab.value)) {
activeDashboardTab.value = DEFAULT_DASHBOARD_TAB;
}
},
{ immediate: true }
);
const loadStatus = async ({ force = false } = {}) => {
const [snapshotValue] = await Promise.all([
getSuperuserSystemStatus({ force }),
@@ -725,250 +707,203 @@ function modulePath(key) {
</ul>
</div>
<b-tabs
v-model="activeDashboardTab"
expanded
type="is-boxed"
class="system-status-tabs"
data-testid="system-status-dashboard-tabs"
>
<b-tab-item value="infrastructure">
<template #header>
<b-icon pack="fas" icon="server" />
<span data-testid="system-status-tab-infrastructure">
{{ $t("system_status.sections.infrastructure") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-infrastructure">
<div class="section-heading">
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
<RouterLink class="section-link" to="/superuser/system/replication">
{{ $t("system_status.actions.open_replication") }}
</RouterLink>
</div>
<div class="status-card-grid">
<article
v-for="card in statusCards"
:key="card.id"
class="status-card"
:class="statusClass(card.status)"
:data-testid="`status-card-${card.id}`"
>
<div class="status-card__top">
<p class="status-card__title">{{ card.title }}</p>
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
</div>
<strong class="status-card__primary">{{ card.primary }}</strong>
<p class="status-card__secondary">{{ card.secondary }}</p>
<small class="status-card__detail">{{ card.detail }}</small>
<small
v-if="card.replicationLabel"
class="status-card__detail status-card__replication"
:data-testid="`status-card-${card.id}-replication`"
>
{{ card.replicationLabel }}
</small>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item v-if="showGatewaySection" value="gateways">
<template #header>
<b-icon pack="fas" icon="network-wired" />
<span data-testid="system-status-tab-gateways">
{{ $t("system_status.sections.gateways") }}
</span>
</template>
<section
class="system-status-section"
data-testid="system-status-gateways"
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
<RouterLink class="section-link" to="/superuser/system/replication">
{{ $t("system_status.actions.open_replication") }}
</RouterLink>
</div>
<div class="status-card-grid">
<article
v-for="card in statusCards"
:key="card.id"
class="status-card"
:class="statusClass(card.status)"
:data-testid="`status-card-${card.id}`"
>
<div class="section-heading">
<div class="status-card__top">
<p class="status-card__title">{{ card.title }}</p>
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
</div>
<strong class="status-card__primary">{{ card.primary }}</strong>
<p class="status-card__secondary">{{ card.secondary }}</p>
<small class="status-card__detail">{{ card.detail }}</small>
<small
v-if="card.replicationLabel"
class="status-card__detail status-card__replication"
:data-testid="`status-card-${card.id}-replication`"
>
{{ card.replicationLabel }}
</small>
</article>
</div>
</section>
<section
v-if="showGatewaySection"
class="system-status-section"
data-testid="system-status-gateways"
>
<div class="section-heading">
<div>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
</div>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
</div>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
</div>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
</div>
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
</div>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
</div>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<article
v-for="module in modules"
:key="module.key"
class="module-card"
:class="statusClass(module.status)"
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
</div>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
</div>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
</div>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.sessions") }}</h3>
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
</div>
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
<div class="table-container sessions-table">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>{{ $t("system_status.table.active") }}</th>
<th>{{ $t("system_status.table.name") }}</th>
<th>{{ $t("system_status.table.type") }}</th>
<th>{{ $t("system_status.table.device") }}</th>
<th>{{ $t("system_status.table.route") }}</th>
<th>{{ $t("system_status.table.first_seen") }}</th>
<th>{{ $t("system_status.table.last_seen") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
</span>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
</div>
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
</b-tab-item>
<b-tab-item value="modules">
<template #header>
<b-icon pack="fas" icon="puzzle-piece" />
<span data-testid="system-status-tab-modules">
{{ $t("system_status.sections.modules") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-modules">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<article
v-for="module in modules"
:key="module.key"
class="module-card"
:class="statusClass(module.status)"
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
</div>
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item value="sessions">
<template #header>
<b-icon pack="fas" icon="users" />
<span data-testid="system-status-tab-sessions">
{{ $t("system_status.sections.sessions") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-sessions">
<div class="section-heading">
<h3>{{ $t("system_status.sections.sessions") }}</h3>
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
</div>
<div class="table-container sessions-table">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>{{ $t("system_status.table.active") }}</th>
<th>{{ $t("system_status.table.name") }}</th>
<th>{{ $t("system_status.table.type") }}</th>
<th>{{ $t("system_status.table.device") }}</th>
<th>{{ $t("system_status.table.route") }}</th>
<th>{{ $t("system_status.table.first_seen") }}</th>
<th>{{ $t("system_status.table.last_seen") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
</span>
</td>
<td>
<strong>{{ sessionDisplayName(session) }}</strong>
<div class="session-context">{{ sessionContextLabel(session) }}</div>
</td>
<td>{{ formatSessionKind(session.session_kind) }}</td>
<td>{{ formatDeviceType(session.device_type) }}</td>
<td class="session-route">{{ session.last_route || "--" }}</td>
<td>{{ formatDate(session.first_seen_at) }}</td>
<td>{{ formatDate(session.last_seen_at) }}</td>
</tr>
<tr v-if="!sessions.recent_sessions?.length">
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</b-tab-item>
</b-tabs>
</td>
<td>
<strong>{{ sessionDisplayName(session) }}</strong>
<div class="session-context">{{ sessionContextLabel(session) }}</div>
</td>
<td>{{ formatSessionKind(session.session_kind) }}</td>
<td>{{ formatDeviceType(session.device_type) }}</td>
<td class="session-route">{{ session.last_route || "--" }}</td>
<td>{{ formatDate(session.first_seen_at) }}</td>
<td>{{ formatDate(session.last_seen_at) }}</td>
</tr>
<tr v-if="!sessions.recent_sessions?.length">
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
@@ -989,10 +924,10 @@ function modulePath(key) {
.module-card,
.gateway-card {
border: 1px solid #d7dde7;
border-radius: 8px;
border-radius: 18px;
padding: 1rem 1.1rem;
background: #ffffff;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06);
}
.summary-card--overall {
@@ -1056,23 +991,6 @@ function modulePath(key) {
color: #0f4c81;
}
.system-status-tabs {
min-width: 0;
}
.system-status-tabs :deep(.tab-content) {
padding: 1rem 0 0;
}
.system-status-tabs :deep(.tabs ul) {
align-items: stretch;
}
.system-status-tabs :deep(.tabs a) {
min-height: 2.75rem;
gap: 0.35rem;
}
.status-card__top,
.module-card__top {
display: flex;
@@ -1189,7 +1107,7 @@ function modulePath(key) {
.gateway-empty {
border: 1px dashed #cbd5e1;
border-radius: 8px;
border-radius: 18px;
padding: 1rem 1.1rem;
color: #475569;
background: rgba(248, 250, 252, 0.8);
@@ -12,11 +12,6 @@ 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: {
@@ -131,34 +126,32 @@ 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;
}
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;
/** 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);
}
)
}
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
@@ -217,7 +210,6 @@ const isOrderContentVisible = (order) => {
class="button is-small"
@click="onInvoiceCollections()"
:disabled="!isAnyOrderSelected()"
data-testid="invoice-order-table-invoice-button"
>
{{ $t('global.invoice_now') }}
</button>
@@ -282,4 +274,4 @@ const isOrderContentVisible = (order) => {
<style scoped>
</style>
</style>
@@ -13,7 +13,6 @@ import { showDownloadWashCertificate } from "@/components/shop/DownloadWashCerti
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import Swal from 'sweetalert2';
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
const redirectBookingObjectPage = (objectId) => {
// Send the user to the object page
window.location.href = `/user/bookings/${objectId}`;
@@ -238,8 +237,7 @@ window.addEventListener('resize', () => {
* To organize the bookings, we want to show the bookings that are created today first, then yesterday, then all other days
* @type {string}
*/
const currentDate = todayLocalDateOnly();
const previousDate = yesterdayLocalDateOnly();
const currentDate = new Date().toISOString().split("T")[0];
/**
* Sort the bookings by date
@@ -364,7 +362,7 @@ const canUserEditObject = (object) => {
</span>
<span class="has-text-grey">
<!-- Human readable date (Today, Yesterday, etc.) -->
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === previousDate ? $t('tables.bookings.yesterday') : object.date }}
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split("T")[0] ? $t('tables.bookings.yesterday') : object.date }}
<!-- Number of bookings on the date -->
({{ $t('tables.bookings.bookings_count', { count: objects.filter((booking) => booking.date === object.date).length }) }})
</span>
@@ -45,11 +45,6 @@ const reload = () => {
}
};
const canViewVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "list");
const canEditVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "edit");
const canDeleteVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "delete");
const canShowVehicleRowActions = () => canViewVehicle() || canDeleteVehicle();
const redirectUserVehiclePage = (vehicleId) => {
// Send the user to the vehicle page
window.location.href = `/user/vehicles/${vehicleId}`;
@@ -151,7 +146,6 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="reg"
/>
<!-- Type -->
@@ -159,7 +153,6 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="type"
:parse-function="
(value) => {
@@ -172,7 +165,6 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="wash_subscription"
:parse-function="
(value) => {
@@ -182,7 +174,7 @@ const getProductOptionsLabel = (vehicle) => {
/>
<!-- Product Options, if the wash subscription is set to true -->
<td v-if="!props.compact">
<template v-if="object.wash_subscription && canEditVehicle()">
<template v-if="object.wash_subscription">
<!-- Enabled subscription -->
<ActionSettingsWheelButton
:label="getProductOptionsLabel(object)"
@@ -226,14 +218,13 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="reference"
/>
<!-- Actions -->
<td v-if="!props.compact">
<td>
<!-- Actions stay grouped behind the wheel menu. -->
<ActionSettingsWheelButton
v-if="canShowVehicleRowActions()"
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
@@ -241,14 +232,12 @@ const getProductOptionsLabel = (vehicle) => {
<template #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
v-if="canViewVehicle()"
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-eye"
:click-action="() => redirectUserVehiclePage(object.id)"
/>
<!-- Delete -->
<ActionSettingsWheelItem
v-if="canDeleteVehicle()"
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
@@ -19,17 +19,17 @@ const SENSITIVE_QUERY_KEYS = /authorization|password|passwd|secret|token|api[_-]
const FALLBACK_LABELS = {
"error_report.button": "Report error",
"error_report.title": "Report error",
"error_report.subtitle": "Send recent error details to support. A screenshot is attached when available.",
"error_report.subtitle": "Send the current screen and recent error details to support.",
"error_report.before_error": "What were you doing before the error occurred?",
"error_report.expected": "What did you expect would happen?",
"error_report.actual": "What actually happened?",
"error_report.before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"error_report.expected_placeholder": "Describe the result you expected to see.",
"error_report.actual_placeholder": "Describe what you saw instead, including any error text.",
"error_report.consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"error_report.submit": "Submit report",
"error_report.submitted": "Error report submitted.",
"error_report.capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"error_report.capture_failed": "The screen capture failed. Please try again.",
"error_report.submit_failed": "The error report could not be submitted.",
"error_report.required": "All fields and data collection acceptance are required.",
};
@@ -277,18 +277,15 @@ const submit = async () => {
isSubmitting.value = true;
try {
let screenshot = null;
let screenshot = "";
try {
screenshot = await captureScreenshot();
} catch {
captureError.value = tr("error_report.capture_failed");
return;
}
const context = buildContext();
context.screenshot_attachment = {
status: screenshot ? "stored" : "capture_failed",
attached: Boolean(screenshot),
};
await submitErrorReport({
before_error: form.before_error.trim(),
expected: form.expected.trim(),
@@ -343,7 +340,7 @@ const submit = async () => {
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
{{ tr("error_report.submitted") }}
</div>
<div v-if="captureError" class="notification is-warning is-light" data-testid="error-report-capture-warning">
<div v-if="captureError" class="notification is-danger is-light">
{{ captureError }}
</div>
<div v-if="submitError" class="notification is-danger is-light">
+3 -4
View File
@@ -4,7 +4,6 @@ import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
// Get the department ID from the URL
const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -74,9 +73,9 @@ const emphasis = ref({
"department:" +
department_id +
",status:pending,date-date_from:" +
todayLocalDateOnly() +
new Date().toISOString().split("T")[0] +
",date-date_to:" +
todayLocalDateOnly(),
new Date().toISOString().split("T")[0],
limit: 1, // Limit to 1 booking, we only need to know if there are any bookings or not
page: 1,
})
@@ -98,7 +97,7 @@ const emphasis = ref({
* Check if the department has a daily report for today
*/
SessionUser.request(SessionUser.objects.department_daily_reports.meta.endpoint + "/get", "GET", {
date: todayLocalDateOnly(),
date: new Date().toISOString().split("T")[0],
id: department_id, // This refers to the department ID
})
.then((response) => {
@@ -75,11 +75,19 @@ const items = computed<NavigationItemProps[]>(() => [
* ]
* }
*/
{
label: t('templates.limited_backoffice.title'),
to: `/backoffice`,
type: 'link',
permissions: ['limited_backoffice_access'],
hidden: hasPermission('superuser'),
},
{
label: t('common.settings'),
to: `/user/profile`,
type: 'link',
hidden: !window.location.pathname.startsWith('/user') || !SessionUser.authenticated.value
permissions: ['user'],
hidden: !window.location.pathname.startsWith('/user')
},
{
label: t('common.logout'),
@@ -51,7 +51,7 @@ const items = computed<NavigationItemProps[]>(() => [
to: `/user`,
type: 'link',
// Dashboard visible to regular users and all subusers (subusers always have at least one grant)
permissions: ['user', 'BOOKINGS_LIST', 'BOOKINGS_ADD', 'ORDERS_LIST', 'VEHICLES_LIST', 'VEHICLES_ADD', 'SELFSERVE_LIST', 'SUBUSERS_LIST'],
permissions: ['user', 'BOOKINGS_LIST', 'ORDERS_LIST', 'VEHICLES_LIST', 'SELFSERVE_LIST', 'SUBUSERS_LIST'],
},
{
label: t('common.vehicles'),
@@ -89,66 +89,6 @@ const SUBUSER_MANAGEMENT_PERMISSIONS = {
},
};
const CUSTOMER_FEATURE_PERMISSIONS = {
vehicles: {
list: {
customer: ["user", "list_own_vehicles"],
subuser: ["VEHICLES_LIST"],
},
add: {
customer: ["user", "add_vehicle"],
subuser: ["VEHICLES_ADD"],
},
edit: {
customer: ["edit_vehicle", "add_vehicle"],
subuser: ["VEHICLES_EDIT"],
},
delete: {
customer: ["delete_vehicle"],
subuser: ["VEHICLES_DELETE"],
},
},
bookings: {
list: {
customer: ["user", "list_own_bookings"],
subuser: ["BOOKINGS_LIST"],
},
add: {
customer: ["user"],
subuser: ["BOOKINGS_ADD"],
},
edit: {
customer: ["edit_own_bookings"],
subuser: ["BOOKINGS_EDIT"],
},
delete: {
customer: ["delete_own_bookings"],
subuser: ["BOOKINGS_DELETE"],
},
},
orders: {
list: {
customer: ["user", "list_own_orders"],
subuser: ["ORDERS_LIST"],
},
edit: {
customer: ["edit_own_orders"],
subuser: ["ORDERS_EDIT"],
},
},
selfserve: {
list: {
customer: ["user", "list_own_department_selfserve_vehicle_conditions"],
subuser: ["SELFSERVE_LIST"],
},
add: {
customer: ["add_own_department_selfserve_vehicle_conditions"],
subuser: ["SELFSERVE_ADD"],
},
},
subusers: SUBUSER_MANAGEMENT_PERMISSIONS,
};
const getStoredSessionSnapshot = () => {
if (typeof window === "undefined") {
return null;
@@ -918,13 +858,6 @@ export const SessionUser = {
}
return SessionUser.permissions.value.includes(permission) || SessionUser.permissions.value.includes("superuser");
},
hasAnyPermission: (permissions = []) => {
const permissionList = Array.isArray(permissions) ? permissions : [permissions].filter(Boolean);
if (permissionList.length === 0) {
return true;
}
return permissionList.some((permission) => SessionUser.hasPermission(permission));
},
/** Can access shortcuts */
canAccessAdmin: () => {
return SessionUser.hasPermission("admin") || SessionUser.hasPermission("superuser");
@@ -944,15 +877,6 @@ export const SessionUser = {
? SessionUser.hasPermission(permissions.subuser)
: SessionUser.hasPermission(permissions.customer);
},
canAccessCustomerFeature: (feature, action = "list") => {
const permissions = CUSTOMER_FEATURE_PERMISSIONS[feature]?.[action];
if (!permissions) {
return false;
}
const permissionList = SessionUser.isSubuser.value ? permissions.subuser : permissions.customer;
return SessionUser.hasAnyPermission(permissionList);
},
canAccessDepartment: (id = 0) => {
return SessionUser.hasPermission("department_access_" + parseInt(id)) || SessionUser.canAccessSuperUser();
},
@@ -5,7 +5,6 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import i18n from '@/i18n';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const t = (key) => i18n.global.t(key);
@@ -242,7 +241,7 @@ export const Bookings = {
'/bookings',
'GET',
{
filters: `department:${department},status:pending,date-date_from:${todayLocalDateOnly()},date-date_to:${todayLocalDateOnly()}`,
filters: `department:${department},status:pending,date-date_from:${new Date().toISOString().split('T')[0]},date-date_to:${new Date().toISOString().split('T')[0]}`,
limit: 100,
page: 1
}
@@ -290,4 +289,4 @@ export const Bookings = {
);
}
};
</script>
</script>
@@ -444,14 +444,10 @@ 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;
@@ -5,7 +5,6 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import i18n from '@/i18n';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const t = (key) => i18n.global.t(key);
@@ -81,7 +80,7 @@ const t = (key) => i18n.global.t(key);
water_usage_morning: parseInt(water_usage_morning),
water_usage: parseInt(water_usage),
notes: notes ? notes : 'Daily report',
date: date ? date : todayLocalDateOnly()
date: date ? date : new Date().toISOString().split('T')[0]
}
);
},
@@ -1,7 +1,6 @@
<script>
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
/**
* The DepartmentTimeBookingsEntries object
@@ -231,10 +230,10 @@ export const DepartmentTimeBookingsEntries = {
if (!dateFrom) {
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
dateFrom = formatLocalDateOnly(lastWeek); // Format as YYYY-MM-DD
dateFrom = lastWeek.toISOString().split('T')[0]; // Format as YYYY-MM-DD
}
if (!dateTo) {
dateTo = todayLocalDateOnly(); // Format as YYYY-MM-DD
dateTo = new Date().toISOString().split('T')[0]; // Format as YYYY-MM-DD
}
console.log("Fetching public available times for department:", departmentId, "from", dateFrom, "to", dateTo);
// Make the request to fetch public available times for the department
@@ -280,4 +279,4 @@ export const DepartmentTimeBookingsEntries = {
);
}
};
</script>
</script>
@@ -108,14 +108,6 @@ export const Departments = {
required: false,
},
},
custom_pricing_only: {
label: t("objects.departments.columns.custom_pricing_only"),
type: "boolean",
sortable: true,
creation: {
required: false,
},
},
longitude: {
label: t("objects.departments.columns.longitude"),
type: "number",
@@ -172,14 +164,6 @@ export const Departments = {
archived: async (id, archived) => {
return ObjectsGlobal.set.column(Departments.meta.endpoint, id, "archived", ObjectsGlobal.parse.boolean(archived));
},
custom_pricing_only: async (id, custom_pricing_only) => {
return ObjectsGlobal.set.column(
Departments.meta.endpoint,
id,
"custom_pricing_only",
ObjectsGlobal.parse.boolean(custom_pricing_only)
);
},
longitude: async (id, longitude) => {
return ObjectsGlobal.set.column(Departments.meta.endpoint, id, "longitude", parseFloat(longitude));
},
@@ -6,8 +6,9 @@ import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/men
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
import HeaderAccessShortcuts from "@/components/viewport/page/headers/HeaderAccessShortcuts.vue";
const route = useRoute();
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -40,7 +41,20 @@ const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() &&
<!-- User account name and avatar (Desktop) -->
<div class="navbar-item mr-1">
<div class="buttons">
<HeaderAccessShortcuts />
<!-- Back-Office button -->
<router-link class="button is-white" to="/superuser" v-if="SessionUser.canAccessSuperUser() && !route.path.includes('/superuser')">
<span class="icon">
<i class="fas fa-tools" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('header.backoffice') }}</span>
</router-link>
<!-- Department button -->
<router-link class="button is-white" to="/admin" v-if="SessionUser.canAccessAdmin() && !route.path.includes('/admin')">
<span class="icon">
<i class="fas fa-building" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('common.departments') }}</span>
</router-link>
<!-- Return to own account if impersonating -->
<template v-if="SessionUser.hasSuperUserToken()">
<router-link class="button is-white" to="/auth/return">
@@ -63,7 +63,11 @@ const navigationItems = computed<NavigationMenuItem[]>(() => {
});
const hasPermission = (item: NavigationMenuItem) => {
return SessionUser.hasAnyPermission(item.permissions ?? []);
if (!item.permissions?.length) {
return true;
}
return item.permissions.some((permission) => SessionUser.hasPermission(permission));
};
const isItemVisible = (item: NavigationMenuItem) => !item.hidden && hasPermission(item);
@@ -1,99 +0,0 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { IS_DEV } from "@/config.js";
import SessionUser from "@/components/session/token/SessionUser.vue";
import {
getAdminDepartmentIdFromRoute,
getAdminDepartmentRoute,
getLimitedBackofficeDepartmentIdFromRoute,
getLimitedBackofficeDepartmentRoute,
} from "@/components/viewport/page/headers/headerShortcuts.js";
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const adminDepartmentId = computed(() => getAdminDepartmentIdFromRoute(route));
const backofficeDepartmentId = computed(() => getLimitedBackofficeDepartmentIdFromRoute(route));
const canAccessDepartment = (departmentId) =>
Number.isInteger(departmentId) && departmentId > 0 && SessionUser.canAccessDepartment(departmentId);
const canShowSuperuserBackoffice = computed(
() => SessionUser.canAccessSuperUser() && !route.path.includes("/superuser")
);
const canShowLimitedBackoffice = computed(() => {
const departmentId = adminDepartmentId.value;
return (
canAccessDepartment(departmentId) &&
SessionUser.hasPermission("limited_backoffice_access") &&
!SessionUser.canAccessSuperUser()
);
});
const limitedBackofficeTarget = computed(() => getLimitedBackofficeDepartmentRoute(adminDepartmentId.value));
const adminShortcutTarget = computed(() =>
backofficeDepartmentId.value ? getAdminDepartmentRoute(backofficeDepartmentId.value) : "/admin"
);
const canShowAdminShortcut = computed(() => {
if (!SessionUser.canAccessAdmin() || route.path.includes("/admin")) {
return false;
}
const departmentId = backofficeDepartmentId.value;
return departmentId === null || canAccessDepartment(departmentId);
});
</script>
<template>
<router-link
v-if="canShowSuperuserBackoffice"
class="button is-white"
to="/superuser"
data-testid="superuser-backoffice-header-button"
>
<span class="icon">
<i class="fas fa-tools" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("header.backoffice") }}</span>
</router-link>
<router-link
v-if="canShowLimitedBackoffice"
class="button is-white"
:to="limitedBackofficeTarget"
data-testid="limited-backoffice-header-button"
>
<span class="icon">
<i class="fas fa-tools" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("templates.limited_backoffice.title") }}</span>
</router-link>
<router-link
v-if="canShowAdminShortcut"
class="button is-white"
:to="adminShortcutTarget"
data-testid="admin-header-button"
>
<span class="icon">
<i class="fas fa-building" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("common.departments") }}</span>
</router-link>
</template>
<style scoped>
.transform-color-black {
filter: invert(100%) grayscale(100%) brightness(0) contrast(100%);
}
.transform-color-red {
filter: invert(27%) sepia(96%) saturate(7493%) hue-rotate(357deg) brightness(103%) contrast(101%);
}
</style>
@@ -5,8 +5,9 @@ import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/
import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/menu/CollectedInvoiceQueueMonitor.vue";
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
import HeaderAccessShortcuts from "@/components/viewport/page/headers/HeaderAccessShortcuts.vue";
const route = useRoute();
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -40,7 +41,20 @@ const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() &&
<!-- User account name and avatar (Desktop) -->
<div class="navbar-item mr-1">
<div class="buttons">
<HeaderAccessShortcuts />
<!-- Back-Office button -->
<router-link class="button is-white" to="/superuser" v-if="SessionUser.canAccessSuperUser() && !route.path.includes('/superuser')">
<span class="icon">
<i class="fas fa-tools" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('header.backoffice') }}</span>
</router-link>
<!-- Department button -->
<router-link class="button is-white" to="/admin" v-if="SessionUser.canAccessAdmin() && !route.path.includes('/admin')">
<span class="icon">
<i class="fas fa-building" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('common.departments') }}</span>
</router-link>
<!-- Return to own account if impersonating -->
<template v-if="SessionUser.hasSuperUserToken()">
<router-link class="button is-white" to="/auth/return">
@@ -1,31 +0,0 @@
const parsePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getPath = (route) => String(route?.path ?? "");
export const getAdminDepartmentIdFromRoute = (route) => {
const path = getPath(route);
const fromPath = parsePositiveInteger(path.match(/^\/admin\/(\d+)(?:\/|$)/)?.[1]);
if (fromPath !== null) {
return fromPath;
}
return path.startsWith("/admin") ? parsePositiveInteger(route?.params?.departmentId) : null;
};
export const getLimitedBackofficeDepartmentIdFromRoute = (route) => {
const path = getPath(route);
const fromPath = parsePositiveInteger(path.match(/^\/backoffice\/departments\/(\d+)(?:\/|$)/)?.[1]);
if (fromPath !== null) {
return fromPath;
}
return path.startsWith("/backoffice/departments") ? parsePositiveInteger(route?.params?.departmentId) : null;
};
export const getLimitedBackofficeDepartmentRoute = (departmentId) =>
`/backoffice/departments/${encodeURIComponent(String(departmentId))}/prices`;
export const getAdminDepartmentRoute = (departmentId) => `/admin/${encodeURIComponent(String(departmentId))}`;
@@ -43,24 +43,23 @@ const redirect = async (to: string) => {
}
};
// Permissions and visibility logic
const hasPermission = (permission: string) => {
return SessionUser.hasPermission(permission);
}
const hasDepartmentIdInPath = (path: string) => {
const departmentRegex = /\/admin\/\d+/;
return departmentRegex.test(path);
}
const hasItemPermissions = (item: any) => {
return SessionUser.hasAnyPermission(item?.permissions ?? []);
}
const hasPermissions = computed(() => {
return hasItemPermissions(props.item);
if (!props.item.permissions || props.item.permissions.length === 0) {
return true; // No permissions required, item is visible
}
return props.item.permissions.some(hasPermission);
});
const isVisible = () => {
if (props.item.hidden) {
return false;
}
switch (props.item.type) {
case 'category':
// Check if any child item is visible
@@ -97,14 +96,10 @@ const hasChildren = computed(() => {
});
const isChildVisible = (child: any) => {
if (child.hidden) {
return false;
}
if (child.type === 'department') {
return hasItemPermissions(child) && hasDepartmentIdInPath(router.currentRoute.value.path);
return hasPermissions.value && hasDepartmentIdInPath(router.currentRoute.value.path);
}
return hasItemPermissions(child);
return hasPermissions.value;
}
const hasVisibleChildren = computed(() => {
+3 -44
View File
@@ -1176,9 +1176,6 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1193,15 +1190,6 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -3467,13 +3455,6 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Kun egne priser",
"custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"effective_department_price": "Effektiv afdelingspris"
},
"search_placeholder": "@.capitalize:{'words.generated.søg'} @:{'words.generated.efter'} @:{'words.generated.afdelingsnavn'}",
"select_department": "@.capitalize:{'words.generated.vælg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.afdeling'}",
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.afdelinger'}",
@@ -3497,17 +3478,17 @@
"error_report": {
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send de seneste fejldetaljer til support. Et skærmbillede vedhæftes, når det er muligt.",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at de seneste request-fejl, Vue-fejl, browseroplysninger, mine svar og et app-skærmbillede, når det er muligt, indsamles til fejlfinding.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Rapporten sendes uden skærmbillede.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
@@ -4047,15 +4028,6 @@
"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": {
@@ -4313,7 +4285,6 @@
"departments": {
"columns": {
"archived": "Arkiveret",
"custom_pricing_only": "Ingen fallback-priser",
"dimension": "Dimension",
"economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.afdeling'}",
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
@@ -6039,9 +6010,6 @@
"title": "@.capitalize:{'words.generated.afdelingspriser'}",
"save": "@:{'words.generated.gem'} @:{'words.generated.priser'}",
"saved": "Priserne @:{'words.generated.er'} @:{'words.generated.gemt'}.",
"autosave_saving": "Gemmer priser...",
"autosave_pending": "Prisændringer afventer.",
"autosave_saved": "Alle prisændringer er gemt.",
"product": "Produkt",
"price": "Afdelingspris",
"price_required": "@.capitalize:{'words.generated.alle'} @:{'words.generated.produkter'} @:{'words.generated.skal'} have @:{'words.replication.host_definite_suffix'} @:{'words.generated.ikke'}-negativ numerisk pris.",
@@ -6056,15 +6024,6 @@
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbejder'}",
"name": "Navn",
"email": "Email",
"user_id": "Bruger-id",
"phone": "Telefon",
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.adgangskode'}",
"password_create_help": "@.capitalize:{'words.generated.pakrævet'} @:{'words.generated.for'} nye @:{'words.generated.medarbejdere'}. Brug @:{'words.generated.mindst'} 8 tegn.",
"password_update_help": "Lad feltet være tomt @:{'words.generated.for'} at beholde den nuværende @:{'words.generated.adgangskode'}.",
+3 -36
View File
@@ -1287,9 +1287,6 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1304,15 +1301,6 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -3601,17 +3589,17 @@
"error_report": {
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende die letzten Fehlerdetails an den Support. Ein Screenshot wird angehängt, wenn möglich.",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass letzte Request-Fehler, Vue-Fehler, Browserdetails, meine Antworten und ein App-Screenshot, wenn möglich, zur Fehlersuche erfasst werden.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Der Bericht wird ohne Screenshot gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
@@ -4151,15 +4139,6 @@
"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": {
@@ -6128,9 +6107,6 @@
"title": "@.capitalize:{'words.generated.abteilungspreise'}",
"save": "@:{'words.generated.preise'} @:{'words.generated.speichern'}",
"saved": "@:{'words.generated.preise'} @:{'words.generated.gespeichert'}.",
"autosave_saving": "Preise werden gespeichert...",
"autosave_pending": "Preisaenderungen ausstehend.",
"autosave_saved": "Alle Preisaenderungen gespeichert.",
"product": "@.capitalize:{'words.generated.produkt'}",
"price": "Abteilungspreis",
"price_required": "Jedes @.capitalize:{'words.generated.produkt'} @:{'words.generated.muss'} einen @:{'words.generated.nicht'}-negativen numerischen Preis @:{'words.generated.haben'}.",
@@ -6145,15 +6121,6 @@
"edit_title": "@:{'words.generated.mitarbeiter'} @:{'words.generated.bearbeiten'}",
"name": "Name",
"email": "E-Mail",
"user_id": "Benutzer-ID",
"phone": "Telefon",
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
"country_codes": {
"denmark": "Dänemark",
"sweden": "Schweden",
"norway": "Norwegen",
"finland": "Finnland"
},
"password": "@:{'words.generated.passwort'}",
"password_create_help": "@.capitalize:{'words.generated.fur'} neue @:{'words.generated.mitarbeiter'} @:{'words.generated.erforderlich'}. Verwenden @.capitalize:{'words.generated.sie'} @:{'words.generated.mindestens'} 8 Zeichen.",
"password_update_help": "Leer lassen, um das vorhandene @:{'words.generated.passwort'} beizubehalten.",
+3 -44
View File
@@ -1008,9 +1008,6 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1025,15 +1022,6 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -3299,13 +3287,6 @@
"opening_hours": {
"closed": "@:{'templates.generated.compat.global.closed'}"
},
"pricing": {
"custom_pricing_disabled": "Fallback enabled",
"custom_pricing_enabled": "Custom only",
"custom_pricing_missing_price": "Missing department prices resolve to 999999.",
"custom_pricing_only": "No fallback pricing",
"effective_department_price": "Effective department price"
},
"search_placeholder": "@.capitalize:{'words.generated.search'} @:{'words.generated.by'} @:{'words.generated.department'} @:{'words.generated.name'}",
"select_department": "@.capitalize:{'words.generated.select'} @:{'words.generated.a'} @:{'words.generated.department'}",
"subtitle": "@.capitalize:{'words.generated.manage'} @:{'words.generated.departments'}",
@@ -3329,17 +3310,17 @@
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send recent error details to support. A screenshot is attached when available.",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
@@ -3879,15 +3860,6 @@
"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": {
@@ -4145,7 +4117,6 @@
"departments": {
"columns": {
"archived": "@:{'words.generated.archived'}",
"custom_pricing_only": "No fallback pricing",
"dimension": "Dimension",
"economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @.capitalize:{'words.generated.department'}",
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
@@ -5871,9 +5842,6 @@
"title": "@.capitalize:{'words.generated.department'} @:{'words.generated.prices'}",
"save": "@.capitalize:{'words.generated.save'} @:{'words.generated.prices'}",
"saved": "@.capitalize:{'words.generated.prices'} @:{'words.generated.saved'}.",
"autosave_saving": "Saving prices...",
"autosave_pending": "Price changes pending.",
"autosave_saved": "All price changes saved.",
"product": "@.capitalize:{'words.generated.product'}",
"price": "@.capitalize:{'words.generated.department'} @:{'words.generated.price'}",
"price_required": "Every @:{'words.generated.product'} @:{'words.generated.must'} @:{'words.generated.have'} @:{'words.generated.a'} non-negative numeric @:{'words.generated.price'}.",
@@ -5888,15 +5856,6 @@
"edit_title": "@.capitalize:{'words.generated.edit'} @:{'words.generated.employee'}",
"name": "Name",
"email": "Email",
"user_id": "User ID",
"phone": "Phone",
"phone_validation": "Phone number must be 4-15 digits.",
"country_codes": {
"denmark": "Denmark",
"sweden": "Sweden",
"norway": "Norway",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.password'}",
"password_create_help": "@.capitalize:{'words.generated.required'} @:{'words.generated.for'} new @:{'words.generated.employees'}. Use @:{'words.generated.at'} @:{'words.generated.least'} 8 characters.",
"password_update_help": "Leave blank @:{'words.generated.to'} keep @:{'words.replication.article.host_mention'} existing @:{'words.generated.password'}.",
-93
View File
@@ -2420,13 +2420,6 @@
"title": "@:{'templates.generated.compat.departments.tab.opening_hours'}"
},
"phone": "@:common.phone",
"pricing": {
"custom_pricing_disabled": "@:{'templates.generated.compat.departments.pricing.custom_pricing_disabled'}",
"custom_pricing_enabled": "@:{'templates.generated.compat.departments.pricing.custom_pricing_enabled'}",
"custom_pricing_missing_price": "@:{'templates.generated.compat.departments.pricing.custom_pricing_missing_price'}",
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"effective_department_price": "@:{'templates.generated.compat.departments.pricing.effective_department_price'}"
},
"products": {
"new_product": "@:products.new_product"
},
@@ -3156,15 +3149,6 @@
"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": {
@@ -3502,7 +3486,6 @@
"departments": {
"columns": {
"archived": "@:{'templates.generated.compat.objects.departments.columns.archived'}",
"custom_pricing_only": "@:{'templates.generated.compat.objects.departments.columns.custom_pricing_only'}",
"dimension": "@:{'templates.generated.compat.objects.departments.columns.dimension'}",
"economic_department": "@:{'templates.generated.compat.objects.departments.columns.economic_department'}",
"latitude": "@:{'templates.generated.compat.objects.departments.columns.latitude'}",
@@ -4576,82 +4559,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'}",
+3 -36
View File
@@ -1290,9 +1290,6 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1307,15 +1304,6 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -3604,17 +3592,17 @@
"error_report": {
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send de siste feildetaljene til support. Et skjermbilde legges ved når det er mulig.",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at siste request-feil, Vue-feil, nettleserdetaljer, svarene mine og et app-skjermbilde når det er mulig, samles inn for feilsøking.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Rapporten sendes uten skjermbilde.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
@@ -4154,15 +4142,6 @@
"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": {
@@ -6131,9 +6110,6 @@
"title": "@.capitalize:{'words.generated.avdelingspriser'}",
"save": "@.capitalize:{'words.generated.lagre'} @:{'words.generated.priser'}",
"saved": "Prisene @:{'words.generated.er'} @:{'words.generated.lagret'}.",
"autosave_saving": "Lagrer priser...",
"autosave_pending": "Prisendringer venter.",
"autosave_saved": "Alle prisendringer er lagret.",
"product": "Produkt",
"price": "Avdelingspris",
"price_required": "@.capitalize:{'words.generated.alle'} @:{'words.generated.produkter'} @:{'words.generated.ma'} ha @:{'words.replication.host_definite_suffix'} @:{'words.generated.ikke'}-negativ numerisk pris.",
@@ -6148,15 +6124,6 @@
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbeider'}",
"name": "Navn",
"email": "E-post",
"user_id": "Bruker-ID",
"phone": "Telefon",
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.passord'}",
"password_create_help": "@.capitalize:{'words.generated.kreves'} @:{'words.generated.for'} nye @:{'words.generated.medarbeidere'}. Bruk @:{'words.generated.minst'} 8 tegn.",
"password_update_help": "La stå tomt @:{'words.generated.for'} å beholde nåværende @:{'words.generated.passord'}.",
+3 -36
View File
@@ -1340,9 +1340,6 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1357,15 +1354,6 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -3654,17 +3642,17 @@
"error_report": {
"button": "Rapportera fel",
"title": "Rapportera fel",
"subtitle": "Skicka de senaste feldetaljerna till support. En skärmbild bifogas när det är möjligt.",
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
"before_error": "Vad gjorde du innan felet uppstod?",
"expected": "Vad förväntade du dig skulle hända?",
"actual": "Vad hände faktiskt?",
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
"consent": "Jag accepterar att senaste request-fel, Vue-fel, webbläsardetaljer, mina svar och en app-skärmbild när det är möjligt samlas in för felsökning.",
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
"submit": "Skicka rapport",
"submitted": "Felrapport skickad.",
"capture_failed": "Skärmbilden kunde inte tas. Rapporten skickas utan skärmbild.",
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
"submit_failed": "Felrapporten kunde inte skickas.",
"required": "Alla fält och godkännande av datainsamling krävs.",
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
@@ -4204,15 +4192,6 @@
"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": {
@@ -6181,9 +6160,6 @@
"title": "@.capitalize:{'words.generated.avdelningspriser'}",
"save": "@.capitalize:{'words.generated.spara'} @:{'words.generated.priser'}",
"saved": "Priserna @:{'words.generated.har'} @:{'words.generated.sparats'}.",
"autosave_saving": "Sparar priser...",
"autosave_pending": "Prisandringar vantar.",
"autosave_saved": "Alla prisandringar har sparats.",
"product": "Produkt",
"price": "Avdelningspris",
"price_required": "@.capitalize:{'words.generated.alla'} @:{'words.generated.produkter'} @:{'words.generated.maste'} ha ett icke-negativt numeriskt pris.",
@@ -6198,15 +6174,6 @@
"edit_title": "@:{'words.generated.redigera'} @:{'words.generated.medarbetare'}",
"name": "Namn",
"email": "E-post",
"user_id": "Användar-ID",
"phone": "Telefon",
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.losenord'}",
"password_create_help": "@.capitalize:{'words.generated.kravs'} @:{'words.generated.for'} nya @:{'words.generated.medarbetare'}. Använd @:{'words.generated.minst'} 8 tecken.",
"password_update_help": "Lämna tomt @:{'words.generated.for'} att behålla nuvarande @:{'words.generated.losenord'}.",
@@ -21,13 +21,6 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Kun egne priser",
"custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"effective_department_price": "Effektiv afdelingspris"
},
"search_placeholder": "@.capitalize:{'terms.glossary.søg'} @:{'terms.glossary.efter'} @:{'terms.glossary.afdelingsnavn'}",
"select_department": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.afdeling'}",
"subtitle": "@.capitalize:{'terms.glossary.administrer'} @:{'terms.glossary.afdelinger'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send de seneste fejldetaljer til support. Et skærmbillede vedhæftes, når det er muligt.",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at de seneste request-fejl, Vue-fejl, browseroplysninger, mine svar og et app-skærmbillede, når det er muligt, indsamles til fejlfinding.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Rapporten sendes uden skærmbillede.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
@@ -1,15 +0,0 @@
{
"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"
}
}
}
}
@@ -140,7 +140,6 @@
"departments": {
"columns": {
"archived": "Arkiveret",
"custom_pricing_only": "Ingen fallback-priser",
"dimension": "Dimension",
"economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.afdeling'}",
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
+7 -31
View File
@@ -28,16 +28,13 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -49,15 +46,6 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -277,9 +265,6 @@
"title": "@.capitalize:{'terms.glossary.afdelingspriser'}",
"save": "@:{'terms.glossary.gem'} @:{'terms.glossary.priser'}",
"saved": "Priserne @:{'terms.glossary.er'} @:{'terms.glossary.gemt'}.",
"autosave_saving": "Gemmer priser...",
"autosave_pending": "Prisændringer afventer.",
"autosave_saved": "Alle prisændringer er gemt.",
"product": "Produkt",
"price": "Afdelingspris",
"price_required": "@.capitalize:{'terms.glossary.alle'} @:{'terms.glossary.produkter'} @:{'terms.glossary.skal'} have @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ikke'}-negativ numerisk pris.",
@@ -294,15 +279,6 @@
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbejder'}",
"name": "Navn",
"email": "Email",
"user_id": "Bruger-id",
"phone": "Telefon",
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.adgangskode'}",
"password_create_help": "@.capitalize:{'terms.glossary.pakrævet'} @:{'terms.glossary.for'} nye @:{'terms.glossary.medarbejdere'}. Brug @:{'terms.glossary.mindst'} 8 tegn.",
"password_update_help": "Lad feltet være tomt @:{'terms.glossary.for'} at beholde den nuværende @:{'terms.glossary.adgangskode'}.",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende die letzten Fehlerdetails an den Support. Ein Screenshot wird angehängt, wenn möglich.",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass letzte Request-Fehler, Vue-Fehler, Browserdetails, meine Antworten und ein App-Screenshot, wenn möglich, zur Fehlersuche erfasst werden.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Der Bericht wird ohne Screenshot gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
@@ -1,15 +0,0 @@
{
"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"
}
}
}
}
+7 -31
View File
@@ -28,16 +28,13 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -49,15 +46,6 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -277,9 +265,6 @@
"title": "@.capitalize:{'terms.glossary.abteilungspreise'}",
"save": "@:{'terms.glossary.preise'} @:{'terms.glossary.speichern'}",
"saved": "@:{'terms.glossary.preise'} @:{'terms.glossary.gespeichert'}.",
"autosave_saving": "Preise werden gespeichert...",
"autosave_pending": "Preisaenderungen ausstehend.",
"autosave_saved": "Alle Preisaenderungen gespeichert.",
"product": "@.capitalize:{'terms.glossary.produkt'}",
"price": "Abteilungspreis",
"price_required": "Jedes @.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.muss'} einen @:{'terms.glossary.nicht'}-negativen numerischen Preis @:{'terms.glossary.haben'}.",
@@ -294,15 +279,6 @@
"edit_title": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.bearbeiten'}",
"name": "Name",
"email": "E-Mail",
"user_id": "Benutzer-ID",
"phone": "Telefon",
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
"country_codes": {
"denmark": "Dänemark",
"sweden": "Schweden",
"norway": "Norwegen",
"finland": "Finnland"
},
"password": "@:{'terms.glossary.passwort'}",
"password_create_help": "@.capitalize:{'terms.glossary.fur'} neue @:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.erforderlich'}. Verwenden @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.mindestens'} 8 Zeichen.",
"password_update_help": "Leer lassen, um das vorhandene @:{'terms.glossary.passwort'} beizubehalten.",
@@ -21,13 +21,6 @@
"opening_hours": {
"closed": "@:{'phrases.compat.global.closed'}"
},
"pricing": {
"custom_pricing_disabled": "Fallback enabled",
"custom_pricing_enabled": "Custom only",
"custom_pricing_missing_price": "Missing department prices resolve to 999999.",
"custom_pricing_only": "No fallback pricing",
"effective_department_price": "Effective department price"
},
"search_placeholder": "@.capitalize:{'terms.glossary.search'} @:{'terms.glossary.by'} @:{'terms.glossary.department'} @:{'terms.glossary.name'}",
"select_department": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.a'} @:{'terms.glossary.department'}",
"subtitle": "@.capitalize:{'terms.glossary.manage'} @:{'terms.glossary.departments'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send recent error details to support. A screenshot is attached when available.",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
@@ -1,15 +0,0 @@
{
"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"
}
}
}
}
@@ -140,7 +140,6 @@
"departments": {
"columns": {
"archived": "@:{'terms.glossary.archived'}",
"custom_pricing_only": "No fallback pricing",
"dimension": "Dimension",
"economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @.capitalize:{'terms.glossary.department'}",
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
+7 -31
View File
@@ -28,16 +28,13 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -49,15 +46,6 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -277,9 +265,6 @@
"title": "@.capitalize:{'terms.glossary.department'} @:{'terms.glossary.prices'}",
"save": "@.capitalize:{'terms.glossary.save'} @:{'terms.glossary.prices'}",
"saved": "@.capitalize:{'terms.glossary.prices'} @:{'terms.glossary.saved'}.",
"autosave_saving": "Saving prices...",
"autosave_pending": "Price changes pending.",
"autosave_saved": "All price changes saved.",
"product": "@.capitalize:{'terms.glossary.product'}",
"price": "@.capitalize:{'terms.glossary.department'} @:{'terms.glossary.price'}",
"price_required": "Every @:{'terms.glossary.product'} @:{'terms.glossary.must'} @:{'terms.glossary.have'} @:{'terms.glossary.a'} non-negative numeric @:{'terms.glossary.price'}.",
@@ -294,15 +279,6 @@
"edit_title": "@.capitalize:{'terms.glossary.edit'} @:{'terms.glossary.employee'}",
"name": "Name",
"email": "Email",
"user_id": "User ID",
"phone": "Phone",
"phone_validation": "Phone number must be 4-15 digits.",
"country_codes": {
"denmark": "Denmark",
"sweden": "Sweden",
"norway": "Norway",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.password'}",
"password_create_help": "@.capitalize:{'terms.glossary.required'} @:{'terms.glossary.for'} new @:{'terms.glossary.employees'}. Use @:{'terms.glossary.at'} @:{'terms.glossary.least'} 8 characters.",
"password_update_help": "Leave blank @:{'terms.glossary.to'} keep @:{'terms.replication.article.host_mention'} existing @:{'terms.glossary.password'}.",
@@ -38,13 +38,6 @@
"title": "@:{'phrases.compat.departments.tab.opening_hours'}"
},
"phone": "@:common.phone",
"pricing": {
"custom_pricing_disabled": "@:{'phrases.compat.departments.pricing.custom_pricing_disabled'}",
"custom_pricing_enabled": "@:{'phrases.compat.departments.pricing.custom_pricing_enabled'}",
"custom_pricing_missing_price": "@:{'phrases.compat.departments.pricing.custom_pricing_missing_price'}",
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"effective_department_price": "@:{'phrases.compat.departments.pricing.effective_department_price'}"
},
"products": {
"new_product": "@:products.new_product"
},
@@ -1,13 +0,0 @@
{
"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'}"
}
}
}
@@ -194,7 +194,6 @@
"departments": {
"columns": {
"archived": "@:{'phrases.compat.objects.departments.columns.archived'}",
"custom_pricing_only": "@:{'phrases.compat.objects.departments.columns.custom_pricing_only'}",
"dimension": "@:{'phrases.compat.objects.departments.columns.dimension'}",
"economic_department": "@:{'phrases.compat.objects.departments.columns.economic_department'}",
"latitude": "@:{'phrases.compat.objects.departments.columns.latitude'}",
@@ -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'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send de siste feildetaljene til support. Et skjermbilde legges ved når det er mulig.",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at siste request-feil, Vue-feil, nettleserdetaljer, svarene mine og et app-skjermbilde når det er mulig, samles inn for feilsøking.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Rapporten sendes uten skjermbilde.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
@@ -1,15 +0,0 @@
{
"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"
}
}
}
}
+7 -31
View File
@@ -28,16 +28,13 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -49,15 +46,6 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -277,9 +265,6 @@
"title": "@.capitalize:{'terms.glossary.avdelingspriser'}",
"save": "@.capitalize:{'terms.glossary.lagre'} @:{'terms.glossary.priser'}",
"saved": "Prisene @:{'terms.glossary.er'} @:{'terms.glossary.lagret'}.",
"autosave_saving": "Lagrer priser...",
"autosave_pending": "Prisendringer venter.",
"autosave_saved": "Alle prisendringer er lagret.",
"product": "Produkt",
"price": "Avdelingspris",
"price_required": "@.capitalize:{'terms.glossary.alle'} @:{'terms.glossary.produkter'} @:{'terms.glossary.ma'} ha @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ikke'}-negativ numerisk pris.",
@@ -294,15 +279,6 @@
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbeider'}",
"name": "Navn",
"email": "E-post",
"user_id": "Bruker-ID",
"phone": "Telefon",
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.passord'}",
"password_create_help": "@.capitalize:{'terms.glossary.kreves'} @:{'terms.glossary.for'} nye @:{'terms.glossary.medarbeidere'}. Bruk @:{'terms.glossary.minst'} 8 tegn.",
"password_update_help": "La stå tomt @:{'terms.glossary.for'} å beholde nåværende @:{'terms.glossary.passord'}.",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Rapportera fel",
"title": "Rapportera fel",
"subtitle": "Skicka de senaste feldetaljerna till support. En skärmbild bifogas när det är möjligt.",
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
"before_error": "Vad gjorde du innan felet uppstod?",
"expected": "Vad förväntade du dig skulle hända?",
"actual": "Vad hände faktiskt?",
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
"consent": "Jag accepterar att senaste request-fel, Vue-fel, webbläsardetaljer, mina svar och en app-skärmbild när det är möjligt samlas in för felsökning.",
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
"submit": "Skicka rapport",
"submitted": "Felrapport skickad.",
"capture_failed": "Skärmbilden kunde inte tas. Rapporten skickas utan skärmbild.",
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
"submit_failed": "Felrapporten kunde inte skickas.",
"required": "Alla fält och godkännande av datainsamling krävs.",
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
@@ -1,15 +0,0 @@
{
"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"
}
}
}
}
+7 -31
View File
@@ -28,16 +28,13 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -49,15 +46,6 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -277,9 +265,6 @@
"title": "@.capitalize:{'terms.glossary.avdelningspriser'}",
"save": "@.capitalize:{'terms.glossary.spara'} @:{'terms.glossary.priser'}",
"saved": "Priserna @:{'terms.glossary.har'} @:{'terms.glossary.sparats'}.",
"autosave_saving": "Sparar priser...",
"autosave_pending": "Prisandringar vantar.",
"autosave_saved": "Alla prisandringar har sparats.",
"product": "Produkt",
"price": "Avdelningspris",
"price_required": "@.capitalize:{'terms.glossary.alla'} @:{'terms.glossary.produkter'} @:{'terms.glossary.maste'} ha ett icke-negativt numeriskt pris.",
@@ -294,15 +279,6 @@
"edit_title": "@:{'terms.glossary.redigera'} @:{'terms.glossary.medarbetare'}",
"name": "Namn",
"email": "E-post",
"user_id": "Användar-ID",
"phone": "Telefon",
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.losenord'}",
"password_create_help": "@.capitalize:{'terms.glossary.kravs'} @:{'terms.glossary.for'} nya @:{'terms.glossary.medarbetare'}. Använd @:{'terms.glossary.minst'} 8 tecken.",
"password_update_help": "Lämna tomt @:{'terms.glossary.for'} att behålla nuvarande @:{'terms.glossary.losenord'}.",
-84
View File
@@ -1,84 +0,0 @@
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const DATE_ONLY_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/;
const padDatePart = (value) => String(value).padStart(2, "0");
export const isValidDate = (value) => value instanceof Date && !Number.isNaN(value.getTime());
export const formatLocalDateOnly = (value = new Date()) => {
if (typeof value === "string") {
const directMatch = value.trim().match(DATE_ONLY_PREFIX_PATTERN);
if (directMatch) {
return directMatch[1];
}
}
const date = value instanceof Date ? value : new Date(value);
if (!isValidDate(date)) {
return "";
}
return [
date.getFullYear(),
padDatePart(date.getMonth() + 1),
padDatePart(date.getDate()),
].join("-");
};
export const parseLocalDateOnly = (value) => {
if (value instanceof Date) {
return isValidDate(value)
? new Date(value.getFullYear(), value.getMonth(), value.getDate())
: new Date(Number.NaN);
}
const stringValue = String(value ?? "").trim();
const match = DATE_ONLY_PATTERN.exec(stringValue);
if (match) {
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(year, month - 1, day);
if (
parsed.getFullYear() === year
&& parsed.getMonth() === month - 1
&& parsed.getDate() === day
) {
return parsed;
}
return new Date(Number.NaN);
}
const parsed = new Date(stringValue);
return isValidDate(parsed)
? new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
: parsed;
};
export const todayLocalDateOnly = () => formatLocalDateOnly(new Date());
export const yesterdayLocalDateOnly = () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return formatLocalDateOnly(yesterday);
};
export const startOfLocalDate = (value) => {
const date = parseLocalDateOnly(value);
if (!isValidDate(date)) {
return date;
}
date.setHours(0, 0, 0, 0);
return date;
};
export const endOfLocalDate = (value) => {
const date = parseLocalDateOnly(value);
if (!isValidDate(date)) {
return date;
}
date.setHours(23, 59, 59, 999);
return date;
};
-139
View File
@@ -1,139 +0,0 @@
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
);
};
@@ -16,12 +16,6 @@ import {
} from "@/services/limitedBackoffice.js";
const ROLE_KEYS = ["viewer", "cashier", "booking_coordinator", "operations_lead", "department_admin"];
const PHONE_COUNTRY_CODES = [
{ value: "45", flag: "🇩🇰", labelKey: "denmark" },
{ value: "46", flag: "🇸🇪", labelKey: "sweden" },
{ value: "47", flag: "🇳🇴", labelKey: "norway" },
{ value: "358", flag: "🇫🇮", labelKey: "finland" },
];
const { t } = useI18n();
const departments = ref([]);
@@ -38,8 +32,6 @@ const showRolePermissions = ref(false);
const form = ref({
display_name: "",
email: "",
phone_country_code: "45",
phone: "",
password: "",
role_key: "viewer",
department_ids: [],
@@ -220,41 +212,15 @@ const rolePermissionGroups = (role) =>
const visibleRoles = computed(() => roles.value.filter((role) => ROLE_KEYS.includes(role.key)));
const isEditing = computed(() => Boolean(editingEmployee.value));
const trimmedEmail = computed(() => String(form.value.email || "").trim());
const isEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail.value));
const normalizedPhone = computed(() => String(form.value.phone || "").replace(/\D/g, ""));
const hasPhone = computed(() => normalizedPhone.value.length > 0);
const isPhoneValid = computed(() => !hasPhone.value || /^\d{4,15}$/.test(normalizedPhone.value));
const isPasswordValid = computed(() => isEditing.value || String(form.value.password || "").length >= 8);
const isFormValid = computed(
() =>
String(form.value.display_name || "").trim().length > 0 &&
isEmailValid.value &&
isPhoneValid.value &&
ROLE_KEYS.includes(form.value.role_key) &&
form.value.department_ids.length > 0 &&
isPasswordValid.value
);
const countryCodeLabel = (countryCode) => {
const option = PHONE_COUNTRY_CODES.find((item) => item.value === String(countryCode || ""));
if (!option) {
return "";
}
return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;
};
const formatEmployeePhone = (employee) => {
if (!employee?.phone_country_code || !employee?.phone) {
return "";
}
return `+${employee.phone_country_code} ${employee.phone}`;
};
const sanitizePhone = (event) => {
form.value.phone = String(event?.target?.value || "").replace(/\D/g, "");
};
const loadEmployees = async () => {
const response = await getLimitedBackofficeEmployees({ includeInactive: includeInactive.value });
employees.value = unwrapLimitedBackofficeResponse(response) || [];
@@ -291,8 +257,6 @@ const resetForm = () => {
form.value = {
display_name: "",
email: "",
phone_country_code: "45",
phone: "",
password: "",
role_key: roles.value[0]?.key || "viewer",
department_ids: [],
@@ -305,8 +269,6 @@ const editEmployee = (employee) => {
form.value = {
display_name: employee.display_name || "",
email: employee.email || "",
phone_country_code: employee.phone_country_code ? String(employee.phone_country_code) : "45",
phone: employee.phone ? String(employee.phone) : "",
password: "",
role_key: employee.role?.key || roles.value[0]?.key || "viewer",
department_ids: (employee.departments || []).map((department) => Number(department.id)),
@@ -329,9 +291,7 @@ const setDepartmentChecked = (departmentId, checked) => {
const buildPayload = () => {
const payload = {
display_name: String(form.value.display_name || "").trim(),
email: trimmedEmail.value,
phone_country_code: hasPhone.value ? Number.parseInt(form.value.phone_country_code, 10) : null,
phone: hasPhone.value ? Number.parseInt(normalizedPhone.value, 10) : null,
email: String(form.value.email || "").trim() || null,
role_key: form.value.role_key,
department_ids: form.value.department_ids,
};
@@ -456,51 +416,12 @@ onMounted(() => {
id="limited-employee-email"
v-model="form.email"
class="input"
:class="{ 'is-danger': formAttempted && !isEmailValid }"
type="email"
required
data-testid="limited-employee-email"
/>
</div>
</div>
<div class="field">
<label class="label" for="limited-employee-phone">
{{ t("templates.limited_backoffice.employees.phone") }}
</label>
<div class="limited-employee-phone">
<div class="control">
<div class="select is-fullwidth">
<select
id="limited-employee-phone-country-code"
v-model="form.phone_country_code"
data-testid="limited-employee-phone-country-code"
>
<option v-for="option in PHONE_COUNTRY_CODES" :key="option.value" :value="option.value">
{{ countryCodeLabel(option.value) }}
</option>
</select>
</div>
</div>
<div class="control">
<input
id="limited-employee-phone"
v-model="form.phone"
class="input"
:class="{ 'is-danger': formAttempted && !isPhoneValid }"
type="tel"
inputmode="numeric"
pattern="[0-9]*"
data-testid="limited-employee-phone"
@input="sanitizePhone"
/>
</div>
</div>
<p v-if="formAttempted && !isPhoneValid" class="help is-danger">
{{ t("templates.limited_backoffice.employees.phone_validation") }}
</p>
</div>
<div class="field">
<label class="label" for="limited-employee-password">
{{ t("templates.limited_backoffice.employees.password") }}
@@ -567,17 +488,15 @@ onMounted(() => {
<div class="field">
<label class="label">{{ t("templates.limited_backoffice.employees.departments") }}</label>
<div class="limited-employee-departments" data-testid="limited-employee-departments">
<b-switch
v-for="department in departments"
:key="department.id"
size="is-small"
type="is-link"
:model-value="departmentChecked(department.id)"
:data-testid="`limited-employee-department-${department.id}`"
@update:model-value="setDepartmentChecked(department.id, $event)"
>
{{ department.name }}
</b-switch>
<label v-for="department in departments" :key="department.id" class="checkbox">
<input
type="checkbox"
:checked="departmentChecked(department.id)"
:data-testid="`limited-employee-department-${department.id}`"
@change="setDepartmentChecked(department.id, $event.target.checked)"
/>
<span>{{ department.name }}</span>
</label>
</div>
<p v-if="formAttempted && form.department_ids.length === 0" class="help is-danger">
{{ t("templates.limited_backoffice.employees.department_required") }}
@@ -588,12 +507,12 @@ onMounted(() => {
{{ t("templates.limited_backoffice.employees.validation") }}
</div>
<div class="limited-employee-actions">
<div class="buttons">
<button
type="submit"
class="button is-dark is-fullwidth"
class="button is-dark"
:class="{ 'is-loading': saving }"
:disabled="saving || !isFormValid"
:disabled="saving"
data-testid="limited-employee-save"
>
<span class="icon is-small"><i class="fas fa-save" aria-hidden="true"></i></span>
@@ -602,7 +521,7 @@ onMounted(() => {
<button
v-if="isEditing"
type="button"
class="button is-light is-fullwidth"
class="button is-light"
data-testid="limited-employee-cancel"
@click="resetForm"
>
@@ -663,13 +582,6 @@ onMounted(() => {
<td>
<strong>{{ employee.display_name }}</strong>
<p v-if="employee.email" class="has-text-grey is-size-7">{{ employee.email }}</p>
<p
v-if="formatEmployeePhone(employee)"
class="has-text-grey is-size-7"
:data-testid="`limited-employee-phone-${employee.id}`"
>
{{ formatEmployeePhone(employee) }}
</p>
</td>
<td>
<strong>{{ roleLabel(employee.role) }}</strong>
@@ -821,14 +733,9 @@ onMounted(() => {
gap: 0.5rem;
}
.limited-employee-phone {
display: grid;
grid-template-columns: minmax(9.5rem, 0.75fr) minmax(0, 1fr);
gap: 0.75rem;
}
.limited-employee-actions {
display: grid;
.limited-employee-departments .checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
}
@@ -959,10 +866,6 @@ onMounted(() => {
.limited-employees-header__inactive {
margin-top: 0.5rem;
}
.limited-employee-phone {
grid-template-columns: 1fr;
}
}
@media screen and (max-width: 768px) {
@@ -38,10 +38,6 @@ const loadDepartments = async () => {
}
};
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
onMounted(() => {
void loadDepartments();
});
@@ -54,7 +50,6 @@ onMounted(() => {
:departments="departments"
:loading-departments="loading"
show-department-switcher
@change-department="changeDepartment"
>
<div v-if="loading" class="notification is-light" data-testid="limited-backoffice-loading">
{{ t("templates.limited_backoffice.loading") }}
+53 -274
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
@@ -16,7 +16,6 @@ import {
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const AUTOSAVE_DELAY_MS = 600;
const departments = ref([]);
const priceData = ref(null);
@@ -25,14 +24,9 @@ const loadingDepartments = ref(false);
const loadingPrices = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const setupRequired = ref(null);
const lastSavedPriceSnapshot = ref("");
let autosaveTimerId = null;
let saveAgainAfterCurrentRequest = false;
let nextSaveSequence = 0;
let lastAppliedSaveSequence = 0;
let nextLoadSequence = 0;
const saveAttempted = ref(false);
const selectedDepartmentId = computed(() => {
const departmentId = Number.parseInt(String(route.params.departmentId ?? ""), 10);
@@ -59,122 +53,7 @@ const invalidPriceCount = computed(
() => products.value.filter((product) => !isPriceValueValid(formPrices.value[product.id])).length
);
const buildFormPricesFromPayload = (payload) =>
productsFromPayload(payload).reduce((prices, product) => {
prices[product.id] = product.price === null || product.price === undefined ? "" : String(product.price);
return prices;
}, {});
const priceRowsFrom = (priceSource, productList = products.value) =>
productList.map((product) => ({
product_id: product.id,
price: String(priceSource[product.id] ?? "").trim(),
}));
const serializePriceRows = (rows) => JSON.stringify(rows);
const priceSnapshotFrom = (priceSource, productList = products.value) =>
serializePriceRows(priceRowsFrom(priceSource, productList));
const hydratePriceState = (payload) => {
const nextFormPrices = buildFormPricesFromPayload(payload);
const productList = productsFromPayload(payload);
priceData.value = payload;
formPrices.value = nextFormPrices;
lastSavedPriceSnapshot.value = priceSnapshotFrom(nextFormPrices, productList);
};
const currentPriceSnapshot = computed(() => (hasProducts.value ? priceSnapshotFrom(formPrices.value) : ""));
const hasUnsavedChanges = computed(
() => hasProducts.value && currentPriceSnapshot.value !== lastSavedPriceSnapshot.value
);
const canAutosave = computed(
() =>
Boolean(selectedDepartmentId.value) &&
hasProducts.value &&
invalidPriceCount.value === 0 &&
!loadingDepartments.value &&
!loadingPrices.value &&
!setupRequired.value &&
!hasForbiddenDepartment.value
);
const autosaveStatusMessage = computed(() => {
if (!hasProducts.value || loadingDepartments.value || loadingPrices.value || setupRequired.value) {
return "";
}
if (saving.value) {
return t("templates.limited_backoffice.prices.autosave_saving");
}
if (hasUnsavedChanges.value && invalidPriceCount.value === 0) {
return t("templates.limited_backoffice.prices.autosave_pending");
}
if (!hasUnsavedChanges.value && lastSavedPriceSnapshot.value !== "") {
return t("templates.limited_backoffice.prices.autosave_saved");
}
return "";
});
const autosaveStatusClass = computed(() => ({
"has-text-grey": saving.value,
"has-text-warning-dark": !saving.value && hasUnsavedChanges.value && invalidPriceCount.value === 0,
"has-text-success": !saving.value && !hasUnsavedChanges.value && lastSavedPriceSnapshot.value !== "",
}));
const autosaveStatusIcon = computed(() => {
if (saving.value) {
return "fa-spinner fa-spin";
}
return hasUnsavedChanges.value ? "fa-clock" : "fa-check";
});
const clearPendingAutosave = () => {
if (autosaveTimerId !== null) {
window.clearTimeout(autosaveTimerId);
autosaveTimerId = null;
}
};
const deduplicateCategoryProducts = (categoryList) => {
const seenProductIds = new Set();
return (Array.isArray(categoryList) ? categoryList : []).map((category) => ({
...category,
products: (Array.isArray(category.products) ? category.products : []).filter((product) => {
const productId = Number(product?.id);
if (!Number.isInteger(productId) || productId <= 0) {
return true;
}
if (seenProductIds.has(productId)) {
return false;
}
seenProductIds.add(productId);
return true;
}),
}));
};
const normalizePriceData = (payload) => {
if (!payload || typeof payload !== "object") {
return payload;
}
return {
...payload,
categories: deduplicateCategoryProducts(payload.categories),
};
};
const productsFromPayload = (payload) =>
deduplicateCategoryProducts(payload?.categories).flatMap((category) => category.products || []);
const canSave = computed(() => hasProducts.value && invalidPriceCount.value === 0 && !saving.value && !loadingPrices.value);
const loadDepartments = async () => {
loadingDepartments.value = true;
@@ -194,12 +73,11 @@ const loadDepartments = async () => {
};
const resetPriceState = () => {
nextLoadSequence += 1;
clearPendingAutosave();
priceData.value = null;
formPrices.value = {};
setupRequired.value = null;
lastSavedPriceSnapshot.value = "";
successMessage.value = "";
saveAttempted.value = false;
};
const loadPrices = async () => {
@@ -208,28 +86,20 @@ const loadPrices = async () => {
return;
}
const loadSequence = ++nextLoadSequence;
clearPendingAutosave();
loadingPrices.value = true;
errorMessage.value = "";
setupRequired.value = null;
priceData.value = null;
formPrices.value = {};
lastSavedPriceSnapshot.value = "";
successMessage.value = "";
try {
const response = await getLimitedBackofficeDepartmentPrices(selectedDepartmentId.value);
if (loadSequence !== nextLoadSequence) {
return;
}
const payload = normalizePriceData(unwrapLimitedBackofficeResponse(response) || null);
hydratePriceState(payload);
const payload = unwrapLimitedBackofficeResponse(response) || null;
priceData.value = payload;
formPrices.value = productsFromPayload(payload).reduce((prices, product) => {
prices[product.id] = product.price === null || product.price === undefined ? "" : String(product.price);
return prices;
}, {});
} catch (error) {
if (loadSequence !== nextLoadSequence) {
return;
}
const payload = error?.response?.data?.data || error?.response?.data || {};
if (Number(error?.response?.status) === 409 && payload?.code === "department_price_setup_required") {
setupRequired.value = payload;
@@ -237,8 +107,6 @@ const loadPrices = async () => {
department: payload.department || selectedDepartment.value,
categories: [],
};
formPrices.value = {};
lastSavedPriceSnapshot.value = "";
} else {
errorMessage.value = limitedBackofficeErrorMessage(
error,
@@ -246,113 +114,45 @@ const loadPrices = async () => {
);
}
} finally {
if (loadSequence === nextLoadSequence) {
loadingPrices.value = false;
}
loadingPrices.value = false;
}
};
const applySuccessfulSavePayload = (updatedPayload, requestedRows, requestedSnapshot) => {
lastSavedPriceSnapshot.value = requestedSnapshot;
if (!updatedPayload || currentPriceSnapshot.value !== requestedSnapshot) {
return;
}
const requestedPriceLookup = requestedRows.reduce((lookup, row) => {
lookup[row.product_id] = row.price;
return lookup;
}, {});
const productList = productsFromPayload(updatedPayload);
const nextFormPrices = productList.reduce((prices, product) => {
prices[product.id] =
requestedPriceLookup[product.id] ??
(product.price === null || product.price === undefined ? "" : String(product.price));
return prices;
}, {});
priceData.value = updatedPayload;
formPrices.value = nextFormPrices;
lastSavedPriceSnapshot.value = priceSnapshotFrom(nextFormPrices, productList);
};
const productsFromPayload = (payload) =>
(Array.isArray(payload?.categories) ? payload.categories : []).flatMap((category) => category.products || []);
const savePrices = async () => {
clearPendingAutosave();
if (!hasUnsavedChanges.value) {
return true;
}
if (!canAutosave.value) {
return false;
}
if (saving.value) {
saveAgainAfterCurrentRequest = true;
return false;
}
const departmentId = selectedDepartmentId.value;
const requestedRows = priceRowsFrom(formPrices.value);
const requestedSnapshot = serializePriceRows(requestedRows);
const saveSequence = ++nextSaveSequence;
let saveCompleted = false;
saveAttempted.value = true;
successMessage.value = "";
errorMessage.value = "";
saving.value = true;
try {
const response = await updateLimitedBackofficeDepartmentPrices(departmentId, requestedRows);
const updatedPayload = normalizePriceData(unwrapLimitedBackofficeResponse(response) || null);
if (selectedDepartmentId.value === departmentId && saveSequence >= lastAppliedSaveSequence) {
lastAppliedSaveSequence = saveSequence;
applySuccessfulSavePayload(updatedPayload, requestedRows, requestedSnapshot);
}
saveCompleted = true;
return true;
} catch (error) {
if (selectedDepartmentId.value === departmentId) {
errorMessage.value = limitedBackofficeErrorMessage(error, t("templates.limited_backoffice.errors.save_prices"));
}
return false;
} finally {
saving.value = false;
const shouldSaveAgain = saveAgainAfterCurrentRequest;
saveAgainAfterCurrentRequest = false;
if (saveCompleted && (shouldSaveAgain || hasUnsavedChanges.value) && canAutosave.value) {
void savePrices();
}
}
};
const scheduleAutosave = () => {
clearPendingAutosave();
if (!hasUnsavedChanges.value || !canAutosave.value) {
if (!canSave.value || !selectedDepartmentId.value) {
return;
}
autosaveTimerId = window.setTimeout(() => {
autosaveTimerId = null;
void savePrices();
}, AUTOSAVE_DELAY_MS);
};
const flushAutosave = async () => {
return savePrices();
saving.value = true;
try {
const payload = products.value.map((product) => ({
product_id: product.id,
price: String(formPrices.value[product.id]).trim(),
}));
const response = await updateLimitedBackofficeDepartmentPrices(selectedDepartmentId.value, payload);
const updatedPayload = unwrapLimitedBackofficeResponse(response) || null;
priceData.value = updatedPayload;
formPrices.value = productsFromPayload(updatedPayload).reduce((prices, product) => {
prices[product.id] = product.price === null || product.price === undefined ? "" : String(product.price);
return prices;
}, {});
saveAttempted.value = false;
successMessage.value = t("templates.limited_backoffice.prices.saved");
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(error, t("templates.limited_backoffice.errors.save_prices"));
} finally {
saving.value = false;
}
};
const changeDepartment = async (departmentId) => {
const flushed = await flushAutosave();
if (!flushed && hasUnsavedChanges.value) {
return;
}
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
@@ -367,22 +167,6 @@ watch(
await loadPrices();
}
);
watch(
formPrices,
() => {
scheduleAutosave();
},
{ deep: true }
);
onBeforeUnmount(() => {
clearPendingAutosave();
if (hasUnsavedChanges.value && canAutosave.value) {
void savePrices();
}
});
</script>
<template>
@@ -413,17 +197,17 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="level-right">
<div
v-if="autosaveStatusMessage"
class="limited-backoffice-prices__autosave"
:class="autosaveStatusClass"
data-testid="limited-prices-autosave-status"
role="status"
aria-live="polite"
<button
type="button"
class="button is-dark"
:class="{ 'is-loading': saving }"
:disabled="!canSave"
data-testid="limited-prices-save"
@click="savePrices"
>
<span class="icon is-small"><i class="fas" :class="autosaveStatusIcon" aria-hidden="true"></i></span>
<span>{{ autosaveStatusMessage }}</span>
</div>
<span class="icon is-small"><i class="fas fa-save" aria-hidden="true"></i></span>
<span>{{ t("templates.limited_backoffice.prices.save") }}</span>
</button>
</div>
</div>
@@ -445,6 +229,10 @@ onBeforeUnmount(() => {
{{ errorMessage }}
</div>
<div v-if="successMessage" class="notification is-success is-light" data-testid="limited-prices-success">
{{ successMessage }}
</div>
<div
v-if="hasProducts && invalidPriceCount > 0"
class="notification is-danger is-light"
@@ -485,7 +273,6 @@ onBeforeUnmount(() => {
required
:aria-label="`${product.name} ${t('templates.limited_backoffice.prices.price')}`"
:data-testid="`limited-price-input-${product.id}`"
@blur="flushAutosave"
/>
</td>
</tr>
@@ -504,14 +291,6 @@ onBeforeUnmount(() => {
margin-bottom: 1rem;
}
.limited-backoffice-prices__autosave {
align-items: center;
display: inline-flex;
font-size: 0.875rem;
gap: 0.25rem;
min-height: 2.25rem;
}
.limited-price-category {
margin-top: 1.25rem;
}
@@ -1,6 +1,7 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import PageTitle from "@/components/global/PageTitle.vue";
const props = defineProps({
@@ -27,6 +28,7 @@ const props = defineProps({
});
const emit = defineEmits(["changeDepartment"]);
const router = useRouter();
const { t } = useI18n();
const pricesRoute = computed(() =>
@@ -48,13 +50,14 @@ const tabs = computed(() => [
},
]);
const selectDepartment = (event) => {
const selectDepartment = async (event) => {
const departmentId = Number.parseInt(String(event?.target?.value ?? ""), 10);
if (!Number.isInteger(departmentId) || departmentId <= 0) {
return;
}
emit("changeDepartment", departmentId);
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
</script>
+9 -11
View File
@@ -62,19 +62,17 @@ const hasAnyShortcutPermission = computed(() => {
// Check if subuser has any of the shortcut permissions
return (
SessionUser.canAccessCustomerFeature("bookings", "add") ||
SessionUser.canAccessCustomerFeature("vehicles", "add") ||
SessionUser.canAccessCustomerFeature("orders", "list") ||
SessionUser.canAccessCustomerFeature("selfserve", "list") ||
SessionUser.canManageSubusers("list")
SessionUser.hasPermission('BOOKINGS_ADD') ||
SessionUser.hasPermission('VEHICLES_ADD') ||
SessionUser.hasPermission('ORDERS_LIST') ||
SessionUser.hasPermission('SELFSERVE_ADD')
);
});
const canShowClassicCustomerShortcut = () => !isSubuser.value && SessionUser.hasPermission('user');
const canShowBookingShortcut = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
const canShowVehicleShortcut = computed(() => SessionUser.canAccessCustomerFeature("vehicles", "add"));
const canShowOrderShortcut = computed(() => SessionUser.canAccessCustomerFeature("orders", "list"));
const canShowSelfServeShortcut = computed(() => SessionUser.canAccessCustomerFeature("selfserve", "list"));
const canShowBookingShortcut = computed(() => canShowClassicCustomerShortcut() || SessionUser.hasPermission('BOOKINGS_ADD'));
const canShowVehicleShortcut = computed(() => canShowClassicCustomerShortcut() || SessionUser.hasPermission('VEHICLES_ADD'));
const canShowOrderShortcut = computed(() => canShowClassicCustomerShortcut() || SessionUser.hasPermission('ORDERS_LIST'));
const canShowInvoiceShortcut = computed(() => canShowClassicCustomerShortcut());
const windowInnerWidth = ref(window.innerWidth);
@@ -169,7 +167,7 @@ const isPermissionsLoading = computed(() => {
{{ $t('messages.welcome') }} {{ SessionUser.getName() }}
</span>
</div>
<div class="column is-half-desktop is-hidden-desktop is-12-tablet" v-if="canShowSelfServeShortcut">
<div class="column is-half-desktop is-hidden-desktop is-12-tablet">
<!-- Start wash card -->
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
<template #header>
@@ -239,7 +237,7 @@ const isPermissionsLoading = computed(() => {
</template>
<template #footer>
<div class="card-footer-item">
<router-link to="/user/vehicles/new" class="button is-link is-fullwidth" :class="classes.button" id="add-vehicle-button">
<router-link to="/user/vehicles" class="button is-link is-fullwidth" :class="classes.button" id="add-vehicle-button">
{{ $t('user_home.create_vehicle') }}
</router-link>
</div>
@@ -19,7 +19,6 @@ import DepartmentDashboardDailyReportNavigation from "@/views/dashboards/departm
import DepartmentDashboardDailyReportProductSales from "@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue";
import DepartmentDashboardDailyReportTodayForm from "@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportTodayForm.vue";
import DepartmentWeather from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentWeather.vue";
import { parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
import {
complaints_metric_state,
count_bookings,
@@ -132,7 +131,7 @@ onMounted(() => {
return [...accessibleDepartmentIds.value];
})();
const initialDateFrom = typeof query.dateFrom === "string" ? query.dateFrom : todayLocalDateOnly();
const initialDateFrom = typeof query.dateFrom === "string" ? query.dateFrom : new Date().toISOString().split("T")[0];
const initialDateTo = typeof query.dateTo === "string" ? query.dateTo : initialDateFrom;
initializeDailyReportFilters({
@@ -171,7 +170,7 @@ watch(
);
const formatDateShort = (dateString) => {
const date = parseLocalDateOnly(dateString);
const date = new Date(dateString);
const dayName = date.toLocaleDateString("da-DK", { weekday: "long" });
const dateValue = date.toLocaleDateString("da-DK", { day: "2-digit", month: "2-digit", year: "2-digit" });
return `${dayName} d. ${dateValue}`;
@@ -179,7 +178,7 @@ const formatDateShort = (dateString) => {
const formatDateRangeSubtitle = (startDateString, endDateString) => {
const dateOptions = { day: "2-digit", month: "2-digit", year: "2-digit" };
return `${parseLocalDateOnly(startDateString).toLocaleDateString("da-DK", dateOptions)} - ${parseLocalDateOnly(endDateString).toLocaleDateString("da-DK", dateOptions)}`;
return `${new Date(startDateString).toLocaleDateString("da-DK", dateOptions)} - ${new Date(endDateString).toLocaleDateString("da-DK", dateOptions)}`;
};
const formatSubtitle = (subtitle) => {
@@ -2,16 +2,18 @@
import { computed, ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { createEmptyOutsideHours, normalizeOutsideHours } from "@/views/dashboards/departmentDashboard/modules/daily-report/outsideHours.js";
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
const GENERATE_UNIQUE_ID = () => Math.random().toString(36).slice(2, 11);
const DATE_TODAY = () => {
return todayLocalDateOnly();
const date = new Date();
return date.toISOString().split("T")[0];
};
const DATE_YESTERDAY = () => {
return yesterdayLocalDateOnly();
const date = new Date();
date.setDate(date.getDate() - 1);
return date.toISOString().split("T")[0];
};
const date_shortcuts = ref({
@@ -17,7 +17,6 @@ import {
isLoading as departmentsStoreLoading,
} from "@/components/pagination/departmentTabs.vue";
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
/**
* Props:
@@ -142,7 +141,14 @@ const toggleAllDepartments = () => {
};
const formatDateSelectionValue = (date) => {
return formatLocalDateOnly(date);
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "";
}
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 onDateSelectionChange = (startDate, endDate) => {
@@ -155,7 +161,7 @@ const onDateSelectionChange = (startDate, endDate) => {
<div class="column is-12" data-testid="daily-report-date-controls">
<DatePeriodSelector
:on-selection-change="onDateSelectionChange"
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
:selection="{ startDate: new Date(selected_date), endDate: new Date(selected_date_to) }"
:visibility="{
showDailySelector: true,
showWeeklySelector: true,
@@ -5,7 +5,6 @@ import { parseError, getError, clearErrors} from "@/components/request/HandleGlo
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { selected_date, onChangeCall, selected_department_id} from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import Swal from "sweetalert2";
import { parseLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
const props = defineProps({
department_id: Number,
@@ -24,7 +23,7 @@ const created_at = ref(null);
const id = ref(null);
const message = ref('');
const yesterday = ref({
date: yesterdayLocalDateOnly(),
date: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split('T')[0],
water_usage: 0,
water_usage_morning: 0,
notes: null,
@@ -105,7 +104,7 @@ const getDailyReport = async () => {
// Check if the date is today
const created_at_val = new Date(response.data.data.created_at);
const today = new Date();
if (created_at_val.toDateString() === (selected_date.value ? parseLocalDateOnly(selected_date.value) : today).toDateString()) {
if (created_at_val.toDateString() === (selected_date.value ? new Date(selected_date.value) : today).toDateString()) {
// The daily report is today
// So we can set the values
water_usage.value = parseInt(response.data.data.water_usage);
@@ -213,4 +212,4 @@ onChangeCall(() => {
<style scoped>
</style>
</style>
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { timeBookingsNewScheduler } from '@/views/dashboards/departmentDashboard/modules/time-bookings/book/TimeBookingsNewScheduler.vue';
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const date = ref();
// Set the initial date to the current date YYYY-MM-DD format
const formatDate = (date_input) => {
return formatLocalDateOnly(date_input);
return date_input.toISOString().split('T')[0];
};
date.value = formatDate(new Date());
@@ -16,7 +15,7 @@ watch(timeBookingsNewScheduler.date.selected, (newDate) => {
watch(date, (newDate) => {
// Update the selected date in the scheduler when the date input changes
timeBookingsNewScheduler.date.selected.value = parseLocalDateOnly(newDate);
timeBookingsNewScheduler.date.selected.value = new Date(newDate);
});
</script>
@@ -64,4 +63,4 @@ watch(date, (newDate) => {
<style scoped>
</style>
</style>
@@ -1,7 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const props = defineProps({
department_id: {
@@ -69,7 +68,8 @@ const getBookings = async () => {
};
const getDate = () => {
return todayLocalDateOnly();
const date = new Date();
return date.toISOString().split('T')[0];
};
// Get the department
@@ -2,7 +2,6 @@
import { selected_date, selected_date_to, selectDate as selectDailyReportDate } from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
// Emits for parents that want to react to date changes (non-breaking if unused)
const emit = defineEmits(['date-change']);
@@ -17,12 +16,12 @@ const selectDate = (startIso, endIso) => {
<template>
<DatePeriodSelector
:on-selection-change="(startDate, endDate) => selectDate(formatLocalDateOnly(startDate), formatLocalDateOnly(endDate))"
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
:on-selection-change="(startDate, endDate) => selectDate(startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0])"
:selection="{ startDate: new Date(selected_date), endDate: new Date(selected_date_to) }"
:visibility="{ showDailySelector: true, showWeeklySelector: true, showMultipleMonthWarning: false, showUpdateButton: false }"
:reverse-level-order="true"
@update:selection="(newSelection) => {
selectDate(formatLocalDateOnly(newSelection.startDate), formatLocalDateOnly(newSelection.endDate));
selectDate(newSelection.startDate.toISOString().split('T')[0], newSelection.endDate.toISOString().split('T')[0]);
}"
/>
</template>
@@ -1,6 +1,5 @@
<script lang="ts">
import {defineComponent, ref, watch} from 'vue';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
type fetch = {
@@ -12,8 +11,8 @@ type fetch = {
};
/** Define the variables */
export const show_this_week = ref(false);
export const selected_date = ref(todayLocalDateOnly());
export const selected_date_to = ref(todayLocalDateOnly());
export const selected_date = ref(new Date().toISOString().split('T')[0]);
export const selected_date_to = ref(new Date().toISOString().split('T')[0]);
export const last_fetch_id = ref(0);
export const fetches = ref<fetch[]>([]);
/** Define the functions */
@@ -54,4 +53,4 @@ watch([selected_date, selected_date_to], () => {
export default defineComponent({
name: 'DepartmentsOverviewObject'
});
</script>
</script>
@@ -22,11 +22,6 @@ 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,
@@ -335,13 +330,6 @@ 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(
@@ -363,10 +351,12 @@ 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();
@@ -402,32 +392,10 @@ 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], []);
@@ -1,12 +1,61 @@
<script setup>
import { useRouter} from "vue-router";
import { ref } from 'vue';
import { showAuthSignOutForm } from '@/components/forms/auth/authSignOutForm.vue';
import UserNavigation from "@/components/global/UserNavigation.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import NavigationMenuSuperUser from "@/components/models/navigation/menus/NavigationMenuSuperUser.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const router = useRouter();
const tabs = [
{ name: t('superuser_dashboard.nav.dashboard'), path: '/superuser' },
{ name: t('superuser_dashboard.nav.statistics'), path: '/superuser/statistics' },
{ name: t('common.departments'), path: '/superuser/departments' },
{ name: t('superuser_dashboard.nav.employees'), path: '/superuser/users' },
{ name: t('superuser_dashboard.nav.customers'), path: '/superuser/customers' },
{ name: t('superuser_dashboard.nav.complaints'), path: '/superuser/complaints' },
{ name: t('common.products'), path: '/superuser/products' },
{ name: t('common.invoices'), path: '/superuser/invoices' },
{ name: t('superuser_dashboard.nav.transaction_history'), path: '/superuser/orders' },
{ name: t('superuser_dashboard.nav.scanners'), path: '/superuser/scanners' },
{ name: t('superuser_dashboard.nav.roles'), path: '/superuser/roles' },
{ name: t('common.vehicles'), path: '/superuser/vehicles' },
{ name: SessionUser.objects.categories.meta.title, path: '/superuser/categories' },
{ name: t('superuser_dashboard.nav.xlvask'), path: '/superuser/xlvask' },
{ name: t('superuser_dashboard.nav.configuration'), path: '/superuser/configuration' }
];
// Get the current path
const currentPath = ref(router.currentRoute.value.path);
// Get the index of the active tab
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
// Change the tab
const changeTab = (index) => {
router.push(tabs[index].path);
};
</script>
<template>
<div>
<UserNavigation />
<NavigationMenuSuperUser />
<!--
<div class="tabs is-right">
<ul>
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
<a>{{ tab.name }}</a>
</li>
Sign out button
<li>
<a @click="showAuthSignOutForm">Sign out</a>
</li>
</ul>
</div> -->
</div>
</template>
@@ -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>
@@ -3,15 +3,7 @@ import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
import {
CUSTOM_PRICING_MISSING_PRICE,
setDepartment,
getDepartmentPrices,
getExplicitDepartmentPrice,
editDepartmentPrice,
isCustomPricingOnly,
updateCustomPricingOnly,
} from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { setDepartment, department, departmentId, getDepartmentPrice, editDepartmentPrice } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from 'vue-router'
import { ref } from 'vue';
import { getProducts } from "@/components/shop/Products.vue";
@@ -19,40 +11,14 @@ import { getProducts } from "@/components/shop/Products.vue";
// Get the department from the route
const router = useRouter()
setDepartment(router.currentRoute.value.params.departmentId);
getDepartmentPrices();
// Set the products
const products = ref([]);
const isUpdatingCustomPricingOnly = ref(false);
// Get the products
getProducts().then((response) => {
products.value = response.data.data;
});
const getDepartmentPriceDisplay = (product) => {
const explicitPrice = getExplicitDepartmentPrice(product);
if (explicitPrice !== null) {
return explicitPrice;
}
return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : '-';
};
const isMissingCustomPrice = (product) => {
return isCustomPricingOnly() && getExplicitDepartmentPrice(product) === null;
};
const toggleCustomPricingOnly = async (event) => {
const enabled = event.target.checked;
isUpdatingCustomPricingOnly.value = true;
try {
await updateCustomPricingOnly(enabled);
} catch {
event.target.checked = isCustomPricingOnly();
} finally {
isUpdatingCustomPricingOnly.value = false;
}
};
</script>
<template>
@@ -62,38 +28,12 @@ const toggleCustomPricingOnly = async (event) => {
<PageTitle title="Department" subtitle="Department pricing" />
</template>
<div>
<section class="department-pricing-settings" data-testid="department-custom-pricing-settings">
<div>
<h2 class="title is-5 mb-1">{{ $t('departments.pricing.custom_pricing_only') }}</h2>
<p class="is-size-7 has-text-grey">
{{ $t('departments.pricing.custom_pricing_missing_price') }}
</p>
</div>
<div class="department-pricing-toggle">
<input
id="department-custom-pricing-only"
class="switch is-rounded is-info"
type="checkbox"
:checked="isCustomPricingOnly()"
:disabled="isUpdatingCustomPricingOnly"
data-testid="department-custom-pricing-only-toggle"
@change="toggleCustomPricingOnly"
/>
<label for="department-custom-pricing-only">
{{
isCustomPricingOnly()
? $t('departments.pricing.custom_pricing_enabled')
: $t('departments.pricing.custom_pricing_disabled')
}}
</label>
</div>
</section>
<table class="is-fullwidth table table-striped is-hoverable is-bordered">
<thead>
<tr>
<th>{{ $t('common.product') }}</th>
<th>{{ $t('tables.common.default_price') }}</th>
<th>{{ $t('departments.pricing.effective_department_price') }}</th>
<th>{{ $t('tables.common.department_price') }}</th>
</tr>
</thead>
<tbody>
@@ -103,45 +43,24 @@ const toggleCustomPricingOnly = async (event) => {
<td
@click="editDepartmentPrice(product)"
class="is-clickable"
:class="{ 'has-text-danger has-text-weight-semibold': isMissingCustomPrice(product) }"
:data-testid="`department-price-cell-${product.id}`"
>{{ getDepartmentPriceDisplay(product) }}
>{{ getDepartmentPrice(product) === product.price ? '-' : getDepartmentPrice(product) }}
<i class="is-pulled-right fas fa-edit"></i>
</td>
</tr>
</tbody>
</table>
Department prices...
<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-pricing-settings {
align-items: center;
border: 1px solid #dbdbdb;
border-radius: 6px;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
padding: 1rem;
}
.department-pricing-toggle {
align-items: center;
display: flex;
min-width: 16rem;
}
@media (max-width: 768px) {
.department-pricing-settings {
align-items: flex-start;
flex-direction: column;
}
.department-pricing-toggle {
min-width: 0;
}
}
</style>
</style>
@@ -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>
@@ -4,14 +4,12 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
import Swal from "sweetalert2";
// Define the department id
export const departmentId = ref(0);
export const CUSTOM_PRICING_MISSING_PRICE = 999999;
const default_department = {
id: null,
name: null,
description: null,
branding: null,
custom_pricing_only: false,
created_at: null,
updated_at: null,
prices: []
@@ -36,7 +34,6 @@ export const department = {
id: ref(''),
name: ref(''),
description: ref(''),
custom_pricing_only: ref(false),
created_at: ref(''),
updated_at: ref(''),
prices: ref([])
@@ -52,13 +49,6 @@ const isDepartmentPricesLoaded = ref(false);
// Set the department id
export const setDepartment = (id) => {
departmentId.value = id;
isDepartmentPricesLoaded.value = false;
department.prices.value = [];
department.custom_pricing_only.value = false;
departmentAdvanced.value = {
...default_department,
...default_department_functions
};
// Load the department data
return getDepartmentData();
}
@@ -70,13 +60,11 @@ export const getDepartmentData = async () => {
department.id.value = response.data.data.id;
department.name.value = response.data.data.name;
department.description.value = response.data.data.description;
department.custom_pricing_only.value = Boolean(response.data.data.custom_pricing_only);
department.created_at.value = response.data.data.created_at;
department.updated_at.value = response.data.data.updated_at;
departmentAdvanced.value = {
...default_department,
...response.data.data,
custom_pricing_only: Boolean(response.data.data.custom_pricing_only),
...default_department_functions
};
})
@@ -100,42 +88,17 @@ export const getDepartmentPrice = (product) => {
getDepartmentPrices();
}
if (isDepartmentPricesLoaded.value) {
const price = getExplicitDepartmentPrice(product);
if (price !== null) {
return price;
}
return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : product.price;
const price = department.prices.value.find((price) => price.product_id === product.id);
return price ? price.price : product.price;
}
return product.price;
}
export const getExplicitDepartmentPrice = (product) => {
const price = department.prices.value.find((price) => price.product_id === product.id);
return price ? price.price : null;
}
export const isCustomPricingOnly = () => Boolean(departmentAdvanced.value.custom_pricing_only || department.custom_pricing_only.value);
export const updateCustomPricingOnly = async (enabled) => {
return authenticatedRequest(`/departments`, "PUT", {
id: departmentId.value,
custom_pricing_only: Boolean(enabled)
}).then(async () => {
department.custom_pricing_only.value = Boolean(enabled);
departmentAdvanced.value = {
...departmentAdvanced.value,
custom_pricing_only: Boolean(enabled)
};
await getDepartmentData();
});
}
export const editDepartmentPrice = (product) => {
const explicitPrice = getExplicitDepartmentPrice(product);
Swal.fire({
title: product.name + ' ( product: ' + product.id + ' )',
input: 'number',
inputValue: explicitPrice === null ? '' : explicitPrice,
inputValue: getDepartmentPrice(product),
inputLabel: 'Price',
inputAttributes: {
autocapitalize: 'off'
@@ -620,10 +620,8 @@ onMounted(async () => {
</b-message>
<b-message v-if="monthFallback.usedLegacyDistribution" type="is-warning" has-icon icon-pack="fas" class="mb-0">
<div data-testid="distribution-month-legacy-fallback-warning">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_month') }}</p>
<p v-if="monthFallback.distributionFallbackReason" class="is-size-7">{{ monthFallback.distributionFallbackReason }}</p>
</div>
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_month') }}</p>
<p v-if="monthFallback.distributionFallbackReason" class="is-size-7">{{ monthFallback.distributionFallbackReason }}</p>
</b-message>
<b-tabs v-model="activeTab" expanded data-testid="distribution-month-tabs" class="distribution-tabs">
@@ -901,10 +899,8 @@ onMounted(async () => {
</b-message>
<b-message v-if="compareFallback.usedLegacyCompare" type="is-warning" has-icon icon-pack="fas" class="mb-3">
<div data-testid="distribution-compare-legacy-fallback-warning">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_compare_fallback') }}</p>
<p v-if="compareFallback.compareFallbackReason" class="is-size-7">{{ compareFallback.compareFallbackReason }}</p>
</div>
<p>{{ t('superuser_invoice_distribution.warnings.legacy_compare_fallback') }}</p>
<p v-if="compareFallback.compareFallbackReason" class="is-size-7">{{ compareFallback.compareFallbackReason }}</p>
</b-message>
<section class="v3-compare-summary mb-3" v-if="sortedCompareRows.length">
@@ -209,7 +209,7 @@ onMounted(() => {
icon-pack="fas"
class="mb-0"
>
<div class="fallback-note" data-testid="distribution-overview-legacy-fallback-warning">
<div class="fallback-note">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_fallback', { count: monthsUsingLegacyDistribution.length }) }}</p>
<p v-if="latestDistributionFallbackReason" class="is-size-7">{{ latestDistributionFallbackReason }}</p>
</div>
@@ -601,3 +601,4 @@ onMounted(() => {
outline-offset: 2px;
}
</style>
@@ -13,7 +13,6 @@ import StatisticsDepartmentGoal
from "@/views/dashboards/superUserDashboard/statistics/displays/overview/StatisticsDepartmentGoal.vue";
import {departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
import { useI18n } from 'vue-i18n';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n();
@@ -71,8 +70,7 @@ getDepartments()
// Get the statistics
const getStatistics = async () => {
// Get the statistics
const today = todayLocalDateOnly();
const response = await authenticatedRequest('/statistics/income/departments?start_date=' + today + '&end_date=' + today)
const response = await authenticatedRequest('/statistics/income/departments?start_date=' + (new Date()).toISOString().split('T')[0] + '&end_date=' + (new Date()).toISOString().split('T')[0])
// Get the sent data
statistics_department_today.value = response.data.data
// Log the data
@@ -133,4 +131,4 @@ getStatistics()
<style scoped>
</style>
</style>
@@ -4,7 +4,6 @@ import {ref} from 'vue';
import { Bar } from 'vue-chartjs';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale } from 'chart.js'
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
@@ -17,7 +16,7 @@ const chartOptions = {
// Shortcut to statistics
// Set the time for the statistics to today
SessionUser.adminUser.statistics.set_time(todayLocalDateOnly(), todayLocalDateOnly())
SessionUser.adminUser.statistics.set_time((new Date()).toISOString().split('T')[0], (new Date()).toISOString().split('T')[0])
@@ -34,4 +33,4 @@ SessionUser.adminUser.statistics.set_time(todayLocalDateOnly(), todayLocalDateOn
}"
/>
</div>
</template>
</template>
@@ -1,30 +1,6 @@
<script setup>
import { computed, useAttrs } from "vue";
import DefaultPageWrapper from "@/views/dashboards/DefaultPageWrapper.vue";
import UserLeftMenu from "@/components/menus/user/UserLeftMenu.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
requiredFeature: {
type: String,
default: null,
},
requiredAction: {
type: String,
default: "list",
},
hasPermission: {
type: [Boolean, Function],
default: null,
},
});
const attrs = useAttrs();
const defaultSlots = [
{ name: 'header' },
{ name: 'buttons' },
@@ -33,34 +9,20 @@ const defaultSlots = [
{ name: 'without-formatting' },
]
const resolvedHasPermission = computed(() => {
if (props.hasPermission !== null) {
return typeof props.hasPermission === "function" ? Boolean(props.hasPermission()) : Boolean(props.hasPermission);
}
if (!props.requiredFeature) {
return true;
}
return SessionUser.canAccessCustomerFeature(props.requiredFeature, props.requiredAction);
});
</script>
<template>
<RestrictedPageWrapper :hasPermission="resolvedHasPermission">
<DefaultPageWrapper v-bind="attrs">
<template v-for="slot in defaultSlots" :key="slot.name">
<slot :name="slot.name"></slot>
</template>
<!-- Left menu -->
<template #left-menu>
<UserLeftMenu />
</template>
</DefaultPageWrapper>
</RestrictedPageWrapper>
<DefaultPageWrapper>
<template v-for="slot in defaultSlots" :key="slot.name">
<slot :name="slot.name"></slot>
</template>
<!-- Left menu -->
<template #left-menu>
<UserLeftMenu />
</template>
</DefaultPageWrapper>
</template>
<style scoped>
</style>
</style>
@@ -6,12 +6,7 @@ import OrderBookingsPagination from "@/components/displays/pagination/models/Use
<template>
<div>
<UserDashboardPageWrapper
:title="$t('user_dashboard.bookings.title')"
:subtitle="$t('user_dashboard.bookings.subtitle')"
required-feature="bookings"
required-action="list"
>
<UserDashboardPageWrapper :title="$t('user_dashboard.bookings.title')" :subtitle="$t('user_dashboard.bookings.subtitle')">
<OrderBookingsPagination :filters="{ only_today: true }"/>
<!-- TODO: Complete implementation! -->
</UserDashboardPageWrapper>
@@ -20,4 +15,4 @@ import OrderBookingsPagination from "@/components/displays/pagination/models/Use
<style scoped>
</style>
</style>
@@ -1877,12 +1877,7 @@ watch(customerNumber, (newVal) => {
</div>
</template>
</ViewportResponsiveWrapper>
<UserDashboardPageWrapper
:title="$t('user_dashboard.bookings.title')"
:subtitle="$t('user_dashboard.bookings.book_subtitle')"
required-feature="bookings"
required-action="add"
>
<UserDashboardPageWrapper :title="$t('user_dashboard.bookings.title')" :subtitle="$t('user_dashboard.bookings.book_subtitle')">
<template v-if="false">
<!-- Debug -->
@@ -7,12 +7,7 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
<template>
<div>
<UserDashboardPageWrapper
:title="$t('user_dashboard.bookings.title')"
:subtitle="$t('user_dashboard.bookings.subtitle')"
required-feature="bookings"
required-action="list"
>
<UserDashboardPageWrapper :title="$t('user_dashboard.bookings.title')" :subtitle="$t('user_dashboard.bookings.subtitle')">
<BookingsPagination/>
<!-- TODO: Complete implementation! -->
</UserDashboardPageWrapper>
@@ -21,4 +16,4 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
<style scoped>
</style>
</style>
@@ -7,12 +7,7 @@ import FormDisplay from "@/components/displays/FormDisplay.vue";
<template>
<div>
<UserDashboardPageWrapper
:title="$t('user_dashboard.bookings.title')"
:subtitle="$t('user_dashboard.bookings.book_subtitle')"
required-feature="bookings"
required-action="add"
>
<UserDashboardPageWrapper :title="$t('user_dashboard.bookings.title')" :subtitle="$t('user_dashboard.bookings.book_subtitle')">
<FormDisplay :form_identifier="'BOOK_WASH'" />
</UserDashboardPageWrapper>
</div>
@@ -20,4 +15,4 @@ import FormDisplay from "@/components/displays/FormDisplay.vue";
<style scoped>
</style>
</style>
@@ -162,14 +162,13 @@ const canEditBooking = (bookingobj) => {
// Admins can always edit
if (SessionUser.canAccessAdmin() && SessionUser.hasPermission("edit_bookings")) return true;
if (!!bookingobj.order_id) return false; // Only allow editing bookings without an order
if (SessionUser.canAccessCustomerFeature("bookings", "edit") && isOwnBooking(bookingobj)) return true;
return false;
if (SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj)) return true;
};
const canDeleteBooking = (bookingobj) => {
if (!!bookingobj.order_id) return false;
if (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) return true;
return SessionUser.canAccessCustomerFeature("bookings", "delete") && isOwnBooking(bookingobj);
return SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj);
};
const sortedList = computed(() => {
@@ -2,16 +2,11 @@
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import MyInvoicesPagination from "@/components/displays/pagination/models/UserDashboard/MyInvoicesPagination.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
</script>
<template>
<div>
<UserDashboardPageWrapper
:title="$t('user_dashboard.invoices.title')"
:subtitle="$t('user_dashboard.invoices.subtitle')"
:has-permission="() => !SessionUser.isSubuser.value && SessionUser.hasPermission('user')"
>
<UserDashboardPageWrapper :title="$t('user_dashboard.invoices.title')" :subtitle="$t('user_dashboard.invoices.subtitle')">
<MyInvoicesPagination />
</UserDashboardPageWrapper>
</div>
@@ -19,4 +14,4 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
<style scoped>
</style>
</style>
@@ -6,7 +6,6 @@ import MyMaterialCard from "@/views/dashboards/userDashboard/materials/displays/
//import MyInvoicesPagination from "@/components/displays/pagination/models/UserDashboard/MyInvoicesPagination.vue";
import type { MaterialDepartmentStorageType } from "@/components/types/MaterialDepartmentStorageType.vue";
import type { MaterialType } from "@/components/types/MaterialType.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const material = ref<MaterialType>({
label: "Materialelager 1",
@@ -63,11 +62,7 @@ const material = ref<MaterialType>({
<template>
<div>
<UserDashboardPageWrapper
:title="$t('user_dashboard.materials.title')"
:subtitle="$t('user_dashboard.materials.subtitle')"
:has-permission="() => !SessionUser.isSubuser.value && SessionUser.hasPermission('user')"
>
<UserDashboardPageWrapper :title="$t('user_dashboard.materials.title')" :subtitle="$t('user_dashboard.materials.subtitle')">
<MyMaterialCard
v-bind:material
/>
@@ -77,4 +72,4 @@ const material = ref<MaterialType>({
<style scoped>
</style>
</style>

Some files were not shown because too many files have changed in this diff Show More