Files
pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue
T
Jeppe B f2453ba0a3 Restore expand-all control in invoice period object tree (#248)
Add a persistent recursive expand/collapse control above collected invoices and keep order-item quantity and price in the canonical editable field layout.
2026-08-03 09:03:57 +02:00

3858 lines
128 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import Swal from "sweetalert2";
import { useI18n } from "vue-i18n";
import BuefyTree from "@/components/buefy/tree/BuefyTree.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import InvoicingPeriodFlagBadge from "./InvoicingPeriodFlagBadge.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getAttachmentPreviewKind, releaseObjectUrl } from "@/services/attachmentPreview.js";
import { invoiceQueue } from "../imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
import {
TREE_CATEGORY_TYPES,
TREE_NODE_TYPES,
buildCollectionRootNodes,
buildOrderItemTree,
classifyAttachment,
hasWashCertificateOrderItem,
makeAttachmentNode,
makeBookingItemNode,
makeBookingNode,
makeCategoryNode,
makeEconomicInvoiceNode,
makeNodeId,
makeOrderNode,
makeXlvaskInferredItemNode,
makeXlvaskNode,
} from "../services/invoicingPeriodTreeNodes.js";
type TreeNode = Record<string, any>;
const props = withDefaults(
defineProps<{
customer: any;
transactions: any[];
excludedOrderIds?: any[];
dates: { dateFrom: string; dateTo: string };
invoicePeriodFlags?: any[];
autoExpandAll?: boolean;
}>(),
{
transactions: () => [],
excludedOrderIds: () => [],
invoicePeriodFlags: () => [],
autoExpandAll: false,
}
);
const emit = defineEmits<{
refresh: [];
}>();
const { t, locale } = useI18n();
const treeRef = ref<any>(null);
const checkedKeys = ref<any[]>([]);
const expandedKeys = ref<any[]>([]);
const nodeById = ref<Record<string, TreeNode>>({});
const nodeErrors = ref<Record<string, string>>({});
const orderItemRowsByOrderId = ref<Record<number, any[]>>({});
const attachmentRowsByOrderId = ref<Record<number, any[]>>({});
const activeTreeActionKey = ref<string | null>(null);
const openActionGroupType = ref<string | null>(null);
const activePreviewNodeId = ref<string | null>(null);
const previewLoadingByNodeId = ref<Record<string, boolean>>({});
const previewErrorByNodeId = ref<Record<string, string>>({});
const attachmentPreviewByNodeId = ref<Record<string, any>>({});
const economicDetailsByCollectionId = ref<Record<number, any>>({});
const generatedPreviewObjectUrls = new Set<string>();
const toPositiveInteger = (value: any) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getCollectionSummaryState = (summary: any) =>
String(summary?.invoice_state ?? summary?.economic_state ?? summary?.state ?? "").toLowerCase();
const getCollectionSummaryInvoiceId = (summary: any, invoiceType: "draft" | "booked") => {
const typeSpecificId =
invoiceType === "draft"
? summary?.draft_invoice_id ?? summary?.economic_draft_id ?? summary?.draft_id
: summary?.booked_invoice_id ?? summary?.economic_booked_id ?? summary?.booked_id;
const normalizedSpecificId = toPositiveInteger(typeSpecificId);
if (normalizedSpecificId) {
return normalizedSpecificId;
}
const state = getCollectionSummaryState(summary);
const stateMatchesType =
invoiceType === "draft"
? ["draft", "economic_draft"].includes(state)
: ["booked", "economic_booked"].includes(state);
return stateMatchesType
? toPositiveInteger(summary?.economic_invoice_id ?? summary?.invoice_id ?? summary?.external_id)
: null;
};
const tr = (key: string, fallback: string, params: Record<string, any> = {}) => {
const translated = t(key, params);
return translated === key ? fallback : translated;
};
const localeValue = () => (typeof locale === "string" ? locale : locale.value);
const formatCurrency = (value: any) => SessionUser.functions.currency.toLocal(Number(value || 0));
const treeText = (key: string, fallback: string, params: Record<string, any> = {}) =>
tr(`invoicing_period.object_tree.${key}`, fallback, params);
const commonText = (key: string, fallback: string, params: Record<string, any> = {}) =>
tr(`common.${key}`, fallback, params);
const loadFailedMessage = () => treeText("errors.load_failed", "Kunne ikke indlæse indholdet.");
const openUrlInNewTab = (url: string | null | undefined) => {
if (!url || typeof window === "undefined") {
return;
}
window.open(url, "_blank", "noopener,noreferrer");
};
const resetPreviewState = () => {
generatedPreviewObjectUrls.forEach((url) => releaseObjectUrl(url));
generatedPreviewObjectUrls.clear();
activePreviewNodeId.value = null;
previewLoadingByNodeId.value = {};
previewErrorByNodeId.value = {};
attachmentPreviewByNodeId.value = {};
economicDetailsByCollectionId.value = {};
};
const nodeLabel = {
collection: (id: any) => treeText("nodes.collection", `Fakturasamling #${id}`, { id }),
ordersWithoutCollection: () => treeText("nodes.orders_without_collection", "Orders uden fakturasamling"),
order: (id: any) => treeText("nodes.order", `Vask #${id}`, { id }),
orderItemFallback: (id: any) => treeText("nodes.order_item_fallback", `Linje #${id}`, { id }),
attachmentFallback: (id: any) => treeText("nodes.attachment_fallback", `Vedhæftning #${id}`, { id }),
booking: (id: any) => treeText("nodes.booking", `Booking #${id}`, { id }),
bookingItemFallback: (id: any) => treeText("nodes.booking_item_fallback", `Bookinglinje #${id}`, { id }),
xlvask: (id: any) => treeText("nodes.xlvask", `XL Vask #${id}`, { id }),
xlvaskEmpty: () => treeText("nodes.xlvask_empty", "XL Vask"),
xlvaskItemFallback: (index: number) =>
treeText("nodes.xlvask_item_fallback", `XL Vask linje ${index + 1}`, { index: index + 1 }),
};
const currentSignature = computed(() =>
JSON.stringify({
customer: props.customer?.customer_number,
ids: props.transactions.map((transaction) => transaction?.id),
excluded: props.excludedOrderIds,
dateFrom: props.dates?.dateFrom,
dateTo: props.dates?.dateTo,
autoExpandAll: props.autoExpandAll,
})
);
const rememberNodes = (nodes: TreeNode[]) => {
const next = { ...nodeById.value };
const visit = (node: TreeNode) => {
next[node.id] = node;
if (Array.isArray(node.children)) {
node.children.forEach(visit);
}
};
nodes.forEach(visit);
nodeById.value = next;
return nodes;
};
const rootNodes = ref<TreeNode[]>([]);
const expandableNodeKeys = computed(() =>
Object.values(nodeById.value)
.filter((node) => node?.isLeaf !== true && node?.disabled !== true && node?.id !== undefined && node?.id !== null)
.map((node) => node.id)
);
const hasExpandableNodes = computed(() => expandableNodeKeys.value.length > 0);
const isExpandingAll = computed(() => Boolean(treeRef.value?.bulkExpanding));
const hasNodeLoadErrors = computed(() => Object.keys(nodeErrors.value).length > 0);
const isAllExpanded = computed(
() =>
hasExpandableNodes.value &&
!hasNodeLoadErrors.value &&
expandableNodeKeys.value.every((key) => expandedKeys.value.includes(key))
);
const toggleExpandAll = async () => {
if (!treeRef.value || isExpandingAll.value || !hasExpandableNodes.value) {
return;
}
if (isAllExpanded.value) {
treeRef.value.collapseAll();
return;
}
await treeRef.value.expandAll();
};
watch(
currentSignature,
() => {
checkedKeys.value = [];
expandedKeys.value = [];
nodeById.value = {};
nodeErrors.value = {};
orderItemRowsByOrderId.value = {};
attachmentRowsByOrderId.value = {};
activeTreeActionKey.value = null;
openActionGroupType.value = null;
resetPreviewState();
rootNodes.value = rememberNodes(
buildCollectionRootNodes(props.customer, props.transactions, props.excludedOrderIds, {
collection: nodeLabel.collection,
ordersWithoutCollection: nodeLabel.ordersWithoutCollection(),
dateFrom: props.dates?.dateFrom,
dateTo: props.dates?.dateTo,
locale: localeValue(),
})
);
},
{ immediate: true }
);
const selectedNodes = computed(() =>
checkedKeys.value.map((key) => nodeById.value[String(key)]).filter((node) => node?.actionable === true)
);
const selectedByType = computed(() =>
selectedNodes.value.reduce((groups: Record<string, TreeNode[]>, node: TreeNode) => {
if (!groups[node.type]) {
groups[node.type] = [];
}
groups[node.type].push(node);
return groups;
}, {})
);
const selectedTypes = computed(() => Object.keys(selectedByType.value));
const hasSelection = computed(() => selectedNodes.value.length > 0);
const activeFlags = computed(() =>
props.invoicePeriodFlags.filter((flag: any) => String(flag?.status || "active") === "active")
);
const uniqueValues = (values: any[]) => Array.from(new Set(values.filter(Boolean)));
const nodesOfType = (nodes: TreeNode[], type: string) => nodes.filter((node) => node?.type === type);
const uniqueNodesByKey = (nodes: TreeNode[], keyResolver: (_node: TreeNode) => any) => {
const seen = new Set<string>();
return nodes.filter((node) => {
const key = keyResolver(node);
if (key === undefined || key === null || key === "") {
return false;
}
const normalizedKey = String(key);
if (seen.has(normalizedKey)) {
return false;
}
seen.add(normalizedKey);
return true;
});
};
const collectionIdsFromNodes = (nodes: TreeNode[]) =>
uniqueValues(nodesOfType(nodes, TREE_NODE_TYPES.COLLECTION).map((node) => node.meta.collectionId));
const orderNodesFromNodes = (nodes: TreeNode[]) =>
uniqueNodesByKey(nodesOfType(nodes, TREE_NODE_TYPES.ORDER), (node) => node.meta.orderId);
const orderIdsFromNodes = (nodes: TreeNode[]) =>
orderNodesFromNodes(nodes)
.map((node) => node.meta.orderId)
.filter(Boolean);
const orderItemIdsFromNodes = (nodes: TreeNode[]) =>
uniqueValues(nodesOfType(nodes, TREE_NODE_TYPES.ORDER_ITEM).map((node) => node.meta.itemId));
const attachmentNodesFromNodes = (nodes: TreeNode[]) =>
uniqueNodesByKey(
nodesOfType(nodes, TREE_NODE_TYPES.ATTACHMENT),
(node) => `${node.meta.orderId}:${node.meta.attachmentId}`
);
const bookingIdsFromNodes = (nodes: TreeNode[]) =>
uniqueValues(nodesOfType(nodes, TREE_NODE_TYPES.BOOKING).map((node) => node.meta.bookingId));
const xlvaskNodesFromNodes = (nodes: TreeNode[]) =>
uniqueNodesByKey(nodesOfType(nodes, TREE_NODE_TYPES.XLVASK_WASH), (node) => node.meta.usageId ?? node.id);
const certificateAttachmentOrderIdsFromNodes = (nodes: TreeNode[]) =>
Array.from(
new Set(
attachmentNodesFromNodes(nodes)
.filter((node) => node.meta.attachmentType === "certificate")
.map((node) => node.meta.orderId)
.filter(Boolean)
)
);
const actionableXlvaskNodesFromNodes = (nodes: TreeNode[]) =>
xlvaskNodesFromNodes(nodes).filter((node) => node.meta.usageId);
const collectionIds = () => collectionIdsFromNodes(selectedNodes.value);
const orderNodes = () => orderNodesFromNodes(selectedNodes.value);
const orderIds = () => orderIdsFromNodes(selectedNodes.value);
const orderItemIds = () => orderItemIdsFromNodes(selectedNodes.value);
const attachmentNodes = () => attachmentNodesFromNodes(selectedNodes.value);
const bookingIds = () => bookingIdsFromNodes(selectedNodes.value);
const xlvaskNodes = () => xlvaskNodesFromNodes(selectedNodes.value);
const selectedCollectionCount = computed(() => collectionIds().length);
const selectedOrdersWithBookingCount = computed(
() => orderNodes().filter((node: TreeNode) => node.meta.bookingId).length
);
const selectedOrdersWithWashCount = computed(() => orderNodes().filter((node: TreeNode) => node.meta.washId).length);
const certificateAttachmentOrderIds = computed(() => certificateAttachmentOrderIdsFromNodes(selectedNodes.value));
const actionableXlvaskNodes = computed(() => actionableXlvaskNodesFromNodes(selectedNodes.value));
onBeforeUnmount(() => {
resetPreviewState();
});
const loadTreeNodeChildren = async (node: TreeNode) => {
const requestedSignature = currentSignature.value;
delete nodeErrors.value[node.id];
try {
const children = await loadChildrenForNode(node);
if (requestedSignature !== currentSignature.value) {
return [];
}
await preloadDefaultExpandedChildren(node, children);
if (requestedSignature !== currentSignature.value) {
return [];
}
return rememberNodes(children);
} catch (error: any) {
if (requestedSignature !== currentSignature.value) {
return [];
}
nodeErrors.value[node.id] =
SessionUser.functions.parseErrorMessage?.(error) || error?.message || loadFailedMessage();
throw error;
}
};
const expandTreeNodeKey = (key: string | null | undefined) => {
if (!key || expandedKeys.value.includes(key)) {
return;
}
expandedKeys.value = [...expandedKeys.value, key];
};
const preloadDefaultExpandedChildren = async (node: TreeNode, children: TreeNode[]) => {
if (node.type !== TREE_NODE_TYPES.ORDER) {
return;
}
const orderItemsCategory = children.find((child) => child.category === TREE_CATEGORY_TYPES.ORDER_ITEMS);
if (!orderItemsCategory) {
return;
}
if (!Array.isArray(orderItemsCategory.children)) {
orderItemsCategory.children = await loadCategoryChildren(orderItemsCategory);
orderItemsCategory.isLeaf = orderItemsCategory.children.length === 0;
}
expandTreeNodeKey(orderItemsCategory.id);
};
const loadChildrenForNode = async (node: TreeNode): Promise<TreeNode[]> => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
return buildCollectionCategoryNodes(node);
}
if (node.type === TREE_NODE_TYPES.CATEGORY) {
return loadCategoryChildren(node);
}
if (node.type === TREE_NODE_TYPES.ORDER) {
return loadOrderCategoryNodes(node);
}
if (node.type === TREE_NODE_TYPES.BOOKING) {
return buildBookingCategoryNodes(node);
}
if (node.type === TREE_NODE_TYPES.XLVASK_WASH) {
return buildXlvaskItemNodes(node);
}
return [];
};
const buildCollectionCategoryNodes = (collectionNode: TreeNode) => {
const orders = collectionNode.meta.orders || [];
const categories: TreeNode[] = [];
if (orders.length > 0) {
categories.push(
makeCategoryNode({
id: makeNodeId(
TREE_NODE_TYPES.CATEGORY,
collectionNode.meta.collectionId,
TREE_CATEGORY_TYPES.COLLECTION_ORDERS
),
label: treeText("categories.orders", "Orders"),
category: TREE_CATEGORY_TYPES.COLLECTION_ORDERS,
parentType: collectionNode.type,
parentId: collectionNode.id,
count: orders.length,
icon: "fa-receipt",
checkable: true,
meta: { orders, invoiceState: getNodeInvoiceState(collectionNode) },
})
);
}
if (
props.customer?.meta?.fixed_pricing ||
props.customer?.meta?.wash_subscription ||
props.customer?.meta?.has_vehicle_subscription
) {
categories.push(
makeCategoryNode({
id: makeNodeId(
TREE_NODE_TYPES.CATEGORY,
collectionNode.meta.collectionId,
TREE_CATEGORY_TYPES.COLLECTION_AGREEMENTS
),
label: treeText("categories.agreements", "Betalingsaftaler"),
category: TREE_CATEGORY_TYPES.COLLECTION_AGREEMENTS,
parentType: collectionNode.type,
parentId: collectionNode.id,
count: null,
icon: "fa-file-contract",
meta: {
fixedPricing: props.customer?.meta?.fixed_pricing,
washSubscription: props.customer?.meta?.wash_subscription,
hasVehicleSubscription: props.customer?.meta?.has_vehicle_subscription,
},
})
);
}
if (
orders.some((order: any) => order?.stripe_module_order || order?.stripeModuleOrders || order?.payment_intent_id)
) {
categories.push(
makeCategoryNode({
id: makeNodeId(
TREE_NODE_TYPES.CATEGORY,
collectionNode.meta.collectionId,
TREE_CATEGORY_TYPES.COLLECTION_PAYMENTS
),
label: treeText("categories.payments", "Kortbetalinger"),
category: TREE_CATEGORY_TYPES.COLLECTION_PAYMENTS,
parentType: collectionNode.type,
parentId: collectionNode.id,
icon: "fa-credit-card",
meta: { orders },
})
);
}
if (hasEconomicInvoiceCandidate(collectionNode)) {
const collectionSummary = collectionNode.meta.collectionSummary || null;
categories.push(
makeCategoryNode({
id: makeNodeId(
TREE_NODE_TYPES.CATEGORY,
collectionNode.meta.collectionId,
TREE_CATEGORY_TYPES.COLLECTION_ECONOMIC
),
label: treeText("categories.economic", "Fakturaer"),
category: TREE_CATEGORY_TYPES.COLLECTION_ECONOMIC,
parentType: collectionNode.type,
parentId: collectionNode.id,
icon: "fa-file-invoice",
meta: {
collectionId: collectionNode.meta.collectionId,
draft: props.customer?.draft,
orders,
invoiceState: getNodeInvoiceState(collectionNode),
collectionSummary,
},
})
);
}
return categories;
};
const hasEconomicInvoiceCandidate = (collectionNode: TreeNode) => {
const collectionId = toPositiveInteger(collectionNode.meta.collectionId);
const orders = collectionNode.meta.orders || [];
const collectionSummary = collectionNode.meta.collectionSummary || null;
const draftCollectionIds = Array.isArray(props.customer?.draft?.invoice_collection_ids)
? props.customer.draft.invoice_collection_ids.map((id: any) => toPositiveInteger(id)).filter(Boolean)
: [];
if (collectionId && draftCollectionIds.includes(collectionId)) {
return true;
}
if (
getCollectionSummaryInvoiceId(collectionSummary, "draft") ||
getCollectionSummaryInvoiceId(collectionSummary, "booked") ||
["draft", "economic_draft", "booked", "economic_booked"].includes(getCollectionSummaryState(collectionSummary))
) {
return true;
}
return orders.some((order: any) => {
const invoiceCollection = order?.invoice_collection || {};
return Boolean(
order?.booked ||
order?.external_id ||
order?.booked_invoice_id ||
order?.draft_invoice_id ||
invoiceCollection?.external_id ||
invoiceCollection?.booked_invoice_id ||
invoiceCollection?.draft_invoice_id ||
invoiceCollection?.booked_id ||
invoiceCollection?.draft_id
);
});
};
const loadCategoryChildren = async (node: TreeNode): Promise<TreeNode[]> => {
if (node.category === TREE_CATEGORY_TYPES.COLLECTION_ORDERS) {
return (node.meta.orders || []).map((order: any) =>
makeOrderNode(order, {
label: nodeLabel.order(order?.id),
})
);
}
if (node.category === TREE_CATEGORY_TYPES.COLLECTION_AGREEMENTS) {
return buildAgreementNodes(node);
}
if (node.category === TREE_CATEGORY_TYPES.COLLECTION_PAYMENTS) {
return buildPaymentNodes(node);
}
if (node.category === TREE_CATEGORY_TYPES.COLLECTION_ECONOMIC) {
return buildEconomicInvoiceNodes(node);
}
if (node.category === TREE_CATEGORY_TYPES.ORDER_ITEMS) {
return loadOrderItems(node.meta.orderId);
}
if (
node.category === TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_CERTIFICATES ||
node.category === TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_IMAGES ||
node.category === TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_OTHER
) {
return loadAttachmentNodes(node);
}
if (node.category === TREE_CATEGORY_TYPES.ORDER_BOOKINGS) {
const booking = await SessionUser.objects.order_bookings.get.single(node.meta.bookingId);
return booking ? [makeBookingNode(booking, { label: nodeLabel.booking(booking?.id) })] : [];
}
if (node.category === TREE_CATEGORY_TYPES.ORDER_XLVASK) {
return loadXlvaskNodes(node);
}
if (node.category === TREE_CATEGORY_TYPES.BOOKING_ITEMS) {
return (node.meta.items || []).map((item: any, index: number) =>
makeBookingItemNode({ ...item, id: item?.id ?? index + 1 }, node.meta.bookingId, {
fallbackLabel: nodeLabel.bookingItemFallback(item?.id ?? index + 1),
})
);
}
if (node.category === TREE_CATEGORY_TYPES.XLVASK_ITEMS) {
return (node.meta.items || []).map((item: any, index: number) =>
makeXlvaskInferredItemNode(item, node.meta.washId, index, { fallbackLabel: nodeLabel.xlvaskItemFallback(index) })
);
}
return [];
};
const buildAgreementNodes = (node: TreeNode) => {
const nodes: TreeNode[] = [];
if (node.meta.fixedPricing) {
nodes.push({
id: makeNodeId(TREE_NODE_TYPES.AGREEMENT, "fixed_pricing", props.customer?.customer_number),
label: node.meta.fixedPricing.description || treeText("nodes.fixed_pricing", "Fastpris"),
type: TREE_NODE_TYPES.AGREEMENT,
isLeaf: true,
selectable: true,
actionable: false,
icon: "fa-file-contract",
meta: {
agreementType: "fixed_pricing",
amount: Number(node.meta.fixedPricing.price || 0),
raw: node.meta.fixedPricing,
},
});
}
if (node.meta.washSubscription || node.meta.hasVehicleSubscription) {
nodes.push({
id: makeNodeId(TREE_NODE_TYPES.AGREEMENT, "vehicle_subscription", props.customer?.customer_number),
label: treeText("nodes.vehicle_subscription", "Vaskeabonnement"),
type: TREE_NODE_TYPES.AGREEMENT,
isLeaf: true,
selectable: true,
actionable: false,
icon: "fa-file-contract",
meta: {
agreementType: "vehicle_subscription",
raw: node.meta.washSubscription,
},
});
}
return nodes;
};
const buildPaymentNodes = (node: TreeNode) =>
(node.meta.orders || [])
.filter((order: any) => order?.stripe_module_order || order?.stripeModuleOrders || order?.payment_intent_id)
.map((order: any) => ({
id: makeNodeId(TREE_NODE_TYPES.PAYMENT, order?.payment_intent_id || order?.id),
label: treeText("nodes.payment_for_order", `Kortbetaling for ordre #${order?.id}`, { id: order?.id }),
type: TREE_NODE_TYPES.PAYMENT,
isLeaf: true,
selectable: true,
actionable: false,
icon: "fa-credit-card",
meta: { order, payment: order?.stripe_module_order || order?.stripeModuleOrders },
}));
const buildEconomicInvoiceNodes = async (node: TreeNode) => {
const collectionId = toPositiveInteger(node.meta.collectionId);
const nodes: TreeNode[] = [];
if (!collectionId) {
return nodes;
}
const collectionSummary = node.meta.collectionSummary || {};
const summaryDraftId = getCollectionSummaryInvoiceId(collectionSummary, "draft");
const summaryBookedId = getCollectionSummaryInvoiceId(collectionSummary, "booked");
let details: any;
try {
details = await getEconomicDetails(collectionId);
} catch (error) {
if (!summaryDraftId && !summaryBookedId) {
throw error;
}
details = { collection_summary: collectionSummary };
}
const draftId = toPositiveInteger(details?.economic?.draft_id) || summaryDraftId;
const bookedId = toPositiveInteger(details?.economic?.booked_id) || summaryBookedId;
const economicState = String(
details?.economic?.state || details?.economic_state || getCollectionSummaryState(collectionSummary)
).toLowerCase();
const availablePdfType = String(
details?.economic?.available_pdf_type || details?.available_pdf_type || ""
).toLowerCase();
const isBookedState = ["booked", "economic_booked"].includes(economicState) || availablePdfType === "booked";
const hasDraft = details?.draft?.exists === true || Boolean(summaryDraftId);
if (!isBookedState && draftId && hasDraft) {
nodes.push(
makeEconomicInvoiceNode({
collectionId,
invoiceType: "draft",
economicInvoiceId: draftId,
details,
label: treeText("nodes.economic_draft_with_id", `E-conomic kladde #${draftId}`, { id: draftId }),
})
);
}
if (bookedId) {
nodes.push(
makeEconomicInvoiceNode({
collectionId,
invoiceType: "booked",
economicInvoiceId: bookedId,
details,
label: treeText("nodes.economic_booked_with_id", `E-conomic faktura #${bookedId}`, { id: bookedId }),
})
);
}
return nodes;
};
const getApiRows = (response: any) => (Array.isArray(response?.data?.data) ? response.data.data : []);
const getEconomicDetails = async (collectionId: number) => {
if (economicDetailsByCollectionId.value[collectionId]) {
return economicDetailsByCollectionId.value[collectionId];
}
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.v2.details(collectionId);
const details = getApiPayload(response);
economicDetailsByCollectionId.value = {
...economicDetailsByCollectionId.value,
[collectionId]: details,
};
return details;
};
const getCachedOrderItemRows = async (orderId: number | null) => {
if (!orderId) {
return [];
}
if (orderItemRowsByOrderId.value[orderId]) {
return orderItemRowsByOrderId.value[orderId];
}
const response = await SessionUser.request("/order/items", "GET", { order_id: orderId });
const rows = getApiRows(response);
orderItemRowsByOrderId.value = {
...orderItemRowsByOrderId.value,
[orderId]: rows,
};
return rows;
};
const normalizeAttachmentRows = (attachments: any) => (Array.isArray(attachments) ? attachments : []);
const getCachedAttachmentRows = async (orderId: number | null, seed: any = null) => {
if (!orderId) {
return [];
}
if (attachmentRowsByOrderId.value[orderId]) {
return attachmentRowsByOrderId.value[orderId];
}
const rows = Array.isArray(seed) ? seed : await SessionUser.objects.orders.functions.fetchAttachments(orderId);
attachmentRowsByOrderId.value = {
...attachmentRowsByOrderId.value,
[orderId]: normalizeAttachmentRows(rows),
};
return attachmentRowsByOrderId.value[orderId];
};
const normalizeFlagDefinitionKey = (flag: any) =>
String(flag?.definition_key ?? flag?.definitionKey ?? flag?.key ?? flag?.code ?? flag?.type ?? "").trim();
const flagContext = (flag: any) => flag?.context || flag?.meta || flag?.payload || {};
const flagOrderId = (flag: any) => {
const targetType = normalizeTargetType(flag);
return toPositiveInteger(
flag?.order_id ??
flag?.orderId ??
flagContext(flag)?.order_id ??
flagContext(flag)?.orderId ??
(targetType === "order" || targetType === "order_field" ? normalizeTargetId(flag) : null)
);
};
const hasWashCertificateFlagForOrder = (orderId: number | null) => {
if (!orderId) {
return false;
}
const certificateFlagKeys = new Set([
"wash_certificate_item_without_certificate",
"wash_certificate_attached_without_item",
]);
return activeFlags.value.some(
(flag: any) => certificateFlagKeys.has(normalizeFlagDefinitionKey(flag)) && flagOrderId(flag) === orderId
);
};
const shouldShowWashCertificateCategory = (
orderId: number | null,
orderItems: any[],
attachmentGroups: Record<string, any[]>
) =>
attachmentGroups.certificate.length > 0 ||
hasWashCertificateOrderItem(orderItems) ||
hasWashCertificateFlagForOrder(orderId);
const loadOrderCategoryNodes = async (orderNode: TreeNode) => {
const orderId = nodeOrderId(orderNode);
const order = await loadOrder(orderId, orderNode.meta.order);
const attachmentSeed = Array.isArray(order?.attachments)
? order.attachments
: Array.isArray(orderNode.meta.order?.attachments)
? orderNode.meta.order.attachments
: null;
const [orderItems, attachments] = await Promise.all([
getCachedOrderItemRows(orderId),
getCachedAttachmentRows(orderId, attachmentSeed),
]);
const attachmentGroups = groupAttachments(attachments);
const categories: TreeNode[] = [
makeCategoryNode({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, orderId, TREE_CATEGORY_TYPES.ORDER_ITEMS),
label: treeText("categories.order_items", "Order items"),
category: TREE_CATEGORY_TYPES.ORDER_ITEMS,
parentType: orderNode.type,
parentId: orderNode.id,
icon: "fa-list-check",
checkable: true,
meta: { orderId, items: orderItems, invoiceState: getNodeInvoiceState(orderNode) },
}),
];
if (shouldShowWashCertificateCategory(orderId, orderItems, attachmentGroups)) {
categories.push(
makeAttachmentCategory(
orderId,
TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_CERTIFICATES,
treeText("categories.wash_certificates", "Vaskecertifikater"),
attachmentGroups.certificate
)
);
}
if (attachmentGroups.image.length > 0) {
categories.push(
makeAttachmentCategory(
orderId,
TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_IMAGES,
treeText("categories.images", "Billeder"),
attachmentGroups.image
)
);
}
if (attachmentGroups.other.length > 0) {
categories.push(
makeAttachmentCategory(
orderId,
TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_OTHER,
treeText("categories.other_attachments", "Andre vedhæftninger"),
attachmentGroups.other
)
);
}
if (toPositiveInteger(order?.booking_id)) {
categories.push(
makeCategoryNode({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, orderId, TREE_CATEGORY_TYPES.ORDER_BOOKINGS),
label: treeText("categories.bookings", "Bookinger"),
category: TREE_CATEGORY_TYPES.ORDER_BOOKINGS,
parentType: orderNode.type,
parentId: orderNode.id,
count: 1,
icon: "fa-calendar-check",
meta: { orderId, bookingId: toPositiveInteger(order.booking_id) },
})
);
}
if (String(order?.wash_id || "").trim() !== "") {
categories.push(
makeCategoryNode({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, orderId, TREE_CATEGORY_TYPES.ORDER_XLVASK),
label: treeText("categories.xlvask", "Selvvask"),
category: TREE_CATEGORY_TYPES.ORDER_XLVASK,
parentType: orderNode.type,
parentId: orderNode.id,
count: 1,
icon: "fa-truck-fast",
meta: { orderId, washId: String(order.wash_id), order },
})
);
}
return categories;
};
const loadOrder = async (orderId: number | null, fallback: any = null) => {
if (!orderId) {
return fallback;
}
const loaded = await SessionUser.objects.orders.get.single(orderId);
return loaded || fallback;
};
const nodeOrderId = (node: TreeNode) => toPositiveInteger(node?.meta?.orderId ?? node?.meta?.order?.id);
const loadOrderItems = async (orderId: number) => {
const rows = await getCachedOrderItemRows(orderId);
const orderNode = nodeById.value[makeNodeId(TREE_NODE_TYPES.ORDER, orderId)];
return buildOrderItemTree(rows, {
fallbackLabel: (item: any) => nodeLabel.orderItemFallback(item?.id ?? item?.order_item_id),
invoiceState: getNodeInvoiceState(orderNode),
});
};
const makeAttachmentCategory = (orderId: number, category: string, label: string, attachments: any[]) =>
makeCategoryNode({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, orderId, category),
label,
category,
parentType: TREE_NODE_TYPES.ORDER,
parentId: makeNodeId(TREE_NODE_TYPES.ORDER, orderId),
count: attachments.length,
icon: category === TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_IMAGES ? "fa-image" : "fa-paperclip",
meta: { orderId, attachments },
});
const groupAttachments = (attachments: any[] = []) =>
attachments.reduce(
(groups: Record<string, any[]>, attachment: any) => {
groups[classifyAttachment(attachment)].push(attachment);
return groups;
},
{ certificate: [], image: [], other: [] }
);
const loadAttachmentNodes = async (node: TreeNode) => {
const rows = await getCachedAttachmentRows(node.meta.orderId, node.meta.attachments);
const groups = groupAttachments(rows);
const attachments =
{
[TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_CERTIFICATES]: groups.certificate,
[TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_IMAGES]: groups.image,
[TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_OTHER]: groups.other,
}[node.category] || [];
return attachments.map((attachment: any) =>
makeAttachmentNode(attachment, node.meta.orderId, {
fallbackLabel: nodeLabel.attachmentFallback(attachment?.id ?? attachment?.attachment_id),
})
);
};
const buildBookingCategoryNodes = (bookingNode: TreeNode) => {
const items = Array.isArray(bookingNode.meta.booking?.items) ? bookingNode.meta.booking.items : [];
if (items.length === 0) {
return [];
}
return [
makeCategoryNode({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, bookingNode.meta.bookingId, TREE_CATEGORY_TYPES.BOOKING_ITEMS),
label: treeText("categories.booking_items", "Booking items"),
category: TREE_CATEGORY_TYPES.BOOKING_ITEMS,
parentType: bookingNode.type,
parentId: bookingNode.id,
count: items.length,
icon: "fa-list",
meta: { bookingId: bookingNode.meta.bookingId, items },
}),
];
};
const loadXlvaskNodes = async (node: TreeNode) => {
const rows = await loadUsageRowsForWash(node.meta.washId);
if (rows.length === 0) {
return [
makeXlvaskNode({ WashId: node.meta.washId }, node.meta.order, {
label: nodeLabel.xlvask(node.meta.washId),
emptyLabel: nodeLabel.xlvaskEmpty(),
}),
];
}
return rows.map((usage: any) =>
makeXlvaskNode(usage, node.meta.order, {
label: nodeLabel.xlvask(usage?.WashId ?? usage?.wash_id ?? node.meta.washId),
emptyLabel: nodeLabel.xlvaskEmpty(),
})
);
};
const loadUsageRowsForWash = async (washId: string) => {
if (!washId) {
return [];
}
const response = await SessionUser.request("/modules/xlvask/services/usage/orders", "GET", {
filters: `WashId:${washId}`,
page: 1,
limit: 5,
dateFrom: props.dates?.dateFrom,
dateTo: props.dates?.dateTo,
});
return Array.isArray(response?.data?.data) ? response.data.data : [];
};
const buildXlvaskItemNodes = (node: TreeNode) => {
const items = node.meta.usage?.WashItems || node.meta.usage?.order_items || [];
if (!Array.isArray(items) || items.length === 0) {
return [];
}
return [
makeCategoryNode({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, node.meta.washId || node.meta.usageId, TREE_CATEGORY_TYPES.XLVASK_ITEMS),
label: treeText("categories.xlvask_items", "XL Vask parsed inferred order items"),
category: TREE_CATEGORY_TYPES.XLVASK_ITEMS,
parentType: node.type,
parentId: node.id,
count: items.length,
icon: "fa-list",
meta: { washId: node.meta.washId, items },
}),
];
};
type InvoiceState = "open" | "closed" | "economic_draft" | "economic_booked";
const normalizeInvoiceState = (value: any): InvoiceState | null => {
const normalized = String(value || "")
.trim()
.toLowerCase()
.replace(/-/g, "_");
if (["economic_booked", "booked"].includes(normalized)) {
return "economic_booked";
}
if (["economic_draft", "draft"].includes(normalized)) {
return "economic_draft";
}
if (["closed", "completed"].includes(normalized)) {
return "closed";
}
if (normalized === "open") {
return "open";
}
return null;
};
const stateWeight: Record<InvoiceState, number> = {
open: 0,
closed: 1,
economic_draft: 2,
economic_booked: 3,
};
const highestInvoiceState = (states: Array<InvoiceState | null | undefined>): InvoiceState =>
(states
.filter(Boolean)
.sort(
(left, right) => stateWeight[right as InvoiceState] - stateWeight[left as InvoiceState]
)[0] as InvoiceState) || "open";
const orderInvoiceState = (order: any): InvoiceState => {
const explicitState = normalizeInvoiceState(order?.invoice_state);
if (explicitState) {
return explicitState;
}
const collection = order?.invoice_collection || {};
if (order?.booked || order?.booked_invoice_id || collection?.booked_invoice_id || collection?.booked_id) {
return "economic_booked";
}
if (order?.draft_invoice_id || collection?.draft_invoice_id || collection?.draft_id) {
return "economic_draft";
}
if (order?.closed_at || order?.completed_at || collection?.closed_at) {
return "closed";
}
return "open";
};
const getNodeInvoiceState = (node: TreeNode | null | undefined): InvoiceState => {
if (!node) {
return "open";
}
if (node.type === TREE_NODE_TYPES.ECONOMIC_INVOICE) {
return node.meta?.economicType === "draft" ? "economic_draft" : "economic_booked";
}
const explicitState = normalizeInvoiceState(node.meta?.invoiceState || node.meta?.collectionSummary?.state);
if (explicitState) {
return explicitState;
}
if (node.type === TREE_NODE_TYPES.ORDER) {
return orderInvoiceState(node.meta?.order);
}
const orders =
node.type === TREE_NODE_TYPES.COLLECTION || node.type === TREE_NODE_TYPES.CATEGORY ? node.meta?.orders || [] : [];
if (orders.length > 0) {
return highestInvoiceState(orders.map(orderInvoiceState));
}
return "open";
};
const getNodeIcon = (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
return "fa-file-invoice-dollar";
}
if (node.type === TREE_NODE_TYPES.ORDER) {
return "fa-soap";
}
if (node.type === TREE_NODE_TYPES.ORDER_ITEM) {
return "fa-list-check";
}
if (node.type === TREE_NODE_TYPES.ECONOMIC_INVOICE) {
return "fa-file-invoice";
}
if (node.type === TREE_NODE_TYPES.CATEGORY && node.category === TREE_CATEGORY_TYPES.COLLECTION_ORDERS) {
return "fa-receipt";
}
if (node.type === TREE_NODE_TYPES.CATEGORY && node.category === TREE_CATEGORY_TYPES.COLLECTION_ECONOMIC) {
return "fa-file-invoice";
}
return node.icon || "fa-file";
};
const getNodeIconColorClass = (node: TreeNode) =>
({
open: "has-text-grey",
closed: "has-text-info",
economic_draft: "has-text-warning-dark",
economic_booked: "has-text-success",
}[getNodeInvoiceState(node)]);
const getNodeSubtitle = (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
return `${node.meta.orderCount} orders · ${formatCurrency(node.meta.totalNetAmount)}`;
}
if (node.type === TREE_NODE_TYPES.ORDER) {
return [
node.meta.createdAt,
formatCurrency(node.meta.totalNetAmount),
node.meta.collectionId
? treeText("subtitles.collection", `Samling #${node.meta.collectionId}`, { id: node.meta.collectionId })
: null,
]
.filter(Boolean)
.join(" · ");
}
// Quantity and unit price are rendered in the same field grid as the
// corresponding Buefy table columns. Repeating their calculated total in
// the subtitle makes order-item values appear twice and obscures which
// value is editable.
if (node.type === TREE_NODE_TYPES.ORDER_ITEM) {
return "";
}
if (node.type === TREE_NODE_TYPES.ATTACHMENT) {
return treeText("subtitles.order", `Ordre #${node.meta.orderId}`, { id: node.meta.orderId });
}
if (node.type === TREE_NODE_TYPES.BOOKING) {
return [node.meta.datetime, node.meta.status].filter(Boolean).join(" · ");
}
if (node.type === TREE_NODE_TYPES.XLVASK_WASH) {
return [
node.meta.washId ? treeText("subtitles.wash_id", `WashId ${node.meta.washId}`, { id: node.meta.washId }) : null,
node.meta.orderId ? treeText("subtitles.order", `Ordre #${node.meta.orderId}`, { id: node.meta.orderId }) : null,
]
.filter(Boolean)
.join(" · ");
}
if (node.type === TREE_NODE_TYPES.AGREEMENT && node.meta.amount) {
return formatCurrency(node.meta.amount);
}
return "";
};
const normalizeTargetType = (flag: any) =>
String(flag?.target_type || flag?.targetType || flag?.entity_type || "").trim();
const normalizeTargetId = (flag: any) => toPositiveInteger(flag?.target_id ?? flag?.targetId ?? flag?.entity_id);
const nodeFlagTarget = (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
return { type: "collected_order_invoice", id: node.meta.collectionId };
}
if (node.type === TREE_NODE_TYPES.ORDER) {
return { type: "order", id: node.meta.orderId };
}
if (node.type === TREE_NODE_TYPES.ORDER_ITEM) {
return { type: "order_item", id: node.meta.itemId };
}
if (node.type === TREE_NODE_TYPES.BOOKING) {
return { type: "order_booking", id: node.meta.bookingId };
}
if (node.type === TREE_NODE_TYPES.XLVASK_WASH) {
return { type: "xlvask_usage_log", id: node.meta.usageId };
}
return null;
};
const flagMatchesTarget = (flag: any, type: string, id: number | null) =>
Boolean(id) && normalizeTargetType(flag) === type && normalizeTargetId(flag) === id;
const normalizeFlagField = (flag: any) =>
String(flag?.field || flag?.target_field || "")
.trim()
.toLowerCase();
const getEntityNodeFlags = (node: TreeNode) => {
const target = nodeFlagTarget(node);
if (!target?.id) {
return [];
}
return activeFlags.value.filter((flag: any) => flagMatchesTarget(flag, target.type, target.id));
};
const fieldFlagTarget = (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.ORDER) {
return { type: "order_field", id: toPositiveInteger(node.meta?.orderId) };
}
if (node.type === TREE_NODE_TYPES.ORDER_ITEM) {
return { type: "order_item_field", id: toPositiveInteger(node.meta?.itemId) };
}
return null;
};
const getFieldFlags = (node: TreeNode, fieldKey: string) => {
const target = fieldFlagTarget(node);
if (!target?.id) {
return [];
}
return activeFlags.value.filter(
(flag: any) => flagMatchesTarget(flag, target.type, target.id) && normalizeFlagField(flag) === fieldKey
);
};
const getNodeFlags = (node: TreeNode) => {
const entityFlags = getEntityNodeFlags(node);
if (node.type === TREE_NODE_TYPES.COLLECTION) {
const collectionId = toPositiveInteger(node.meta?.collectionId);
const visibleOrderIds = new Set(
(node.meta?.orders || []).map((order: any) => toPositiveInteger(order?.id)).filter(Boolean)
);
const unmatchedCollectionFlags = activeFlags.value.filter((flag: any) => {
const flagCollectionId = toPositiveInteger(
flag?.invoice_collection_id ??
flagContext(flag)?.invoice_collection_id ??
flagContext(flag)?.invoiceCollectionId
);
const orderId = flagOrderId(flag);
return flagCollectionId === collectionId && Boolean(orderId) && !visibleOrderIds.has(orderId);
});
return [
...new Map(
[...entityFlags, ...unmatchedCollectionFlags].map((flag: any) => [flag?.id || flag?.fingerprint, flag])
).values(),
];
}
const target = fieldFlagTarget(node);
if (!target?.id) {
return entityFlags;
}
const renderedFieldKeys = new Set(visibleNodeFields(node).map((field) => field.key));
const fallbackFieldFlags = activeFlags.value.filter(
(flag: any) => flagMatchesTarget(flag, target.type, target.id) && !renderedFieldKeys.has(normalizeFlagField(flag))
);
return [...entityFlags, ...fallbackFieldFlags];
};
const handleFlagStatusChanged = () => {
emit("refresh");
};
const setNodePreviewLoading = (nodeId: string, isLoading: boolean) => {
const next = { ...previewLoadingByNodeId.value };
if (isLoading) {
next[nodeId] = true;
} else {
delete next[nodeId];
}
previewLoadingByNodeId.value = next;
};
const setNodePreviewError = (nodeId: string, error: any = null) => {
const next = { ...previewErrorByNodeId.value };
if (error) {
next[nodeId] = SessionUser.functions.parseErrorMessage?.(error) || error?.message || String(error);
} else {
delete next[nodeId];
}
previewErrorByNodeId.value = next;
};
const isPreviewableNode = (node: TreeNode) =>
node.type === TREE_NODE_TYPES.ATTACHMENT || node.type === TREE_NODE_TYPES.ECONOMIC_INVOICE;
const isPreviewVisible = (node: TreeNode) => activePreviewNodeId.value === node.id && isPreviewableNode(node);
const isPreviewLoading = (node: TreeNode) => Boolean(previewLoadingByNodeId.value[node.id]);
const previewError = (node: TreeNode) => previewErrorByNodeId.value[node.id] || "";
const ensureAttachmentPreviewSource = async (node: TreeNode) => {
if (attachmentPreviewByNodeId.value[node.id]) {
return attachmentPreviewByNodeId.value[node.id];
}
const attachment = node.meta?.attachment;
const previewKind = getAttachmentPreviewKind(attachment);
const source = {
kind: previewKind,
url: "",
isObjectUrl: false,
};
setNodePreviewLoading(node.id, true);
setNodePreviewError(node.id, null);
try {
const result = await SessionUser.objects.orders.functions.fetchAttachmentContent(
node.meta.orderId,
node.meta.attachmentId,
"inline"
);
const blob = result instanceof Blob ? result : result?.data;
if (!(blob instanceof Blob)) {
throw new Error(treeText("errors.invalid_attachment", "Vedhæftningen kunne ikke indlæses."));
}
const blobType = String(blob.type || "").toLowerCase();
if (blobType.startsWith("image/")) {
source.kind = "image";
} else if (blobType === "application/pdf") {
source.kind = "pdf";
} else if (previewKind === "office") {
source.kind = "download";
}
if (["image", "pdf"].includes(source.kind)) {
source.url = URL.createObjectURL(blob);
source.isObjectUrl = true;
if (source.isObjectUrl && source.url) {
generatedPreviewObjectUrls.add(source.url);
}
}
attachmentPreviewByNodeId.value = {
...attachmentPreviewByNodeId.value,
[node.id]: source,
};
return source;
} catch (error) {
setNodePreviewError(node.id, error);
return null;
} finally {
setNodePreviewLoading(node.id, false);
}
};
const ensureEconomicPreview = async (node: TreeNode) => {
const collectionId = toPositiveInteger(node.meta?.collectionId);
if (!collectionId) {
return null;
}
setNodePreviewLoading(node.id, true);
setNodePreviewError(node.id, null);
try {
return await getEconomicDetails(collectionId);
} catch (error) {
setNodePreviewError(node.id, error);
return null;
} finally {
setNodePreviewLoading(node.id, false);
}
};
const showNodePreview = (node: TreeNode) => {
if (!isPreviewableNode(node)) {
return;
}
activePreviewNodeId.value = node.id;
if (node.type === TREE_NODE_TYPES.ATTACHMENT) {
void ensureAttachmentPreviewSource(node);
}
if (node.type === TREE_NODE_TYPES.ECONOMIC_INVOICE) {
void ensureEconomicPreview(node);
}
};
const hideNodePreview = (node: TreeNode) => {
if (activePreviewNodeId.value === node.id) {
activePreviewNodeId.value = null;
}
};
const attachmentPreviewSource = (node: TreeNode) => attachmentPreviewByNodeId.value[node.id] || null;
const economicDetailsForNode = (node: TreeNode) => {
const collectionId = toPositiveInteger(node.meta?.collectionId);
return (collectionId ? economicDetailsByCollectionId.value[collectionId] : null) || node.meta?.details || null;
};
const economicInvoiceTypeLabel = (type: string) =>
type === "draft" ? treeText("economic.draft", "Kladde") : treeText("economic.booked", "Bogført");
const economicPreviewRows = (node: TreeNode) => {
const details = economicDetailsForNode(node);
const economicType = node.meta?.economicType === "draft" ? "draft" : "booked";
const targetTotals = details?.[economicType]?.normalized?.totals || {};
const internalTotals = details?.internal?.normalized?.totals || {};
return [
{
label: treeText("economic.invoice_type", "Type"),
value: economicInvoiceTypeLabel(economicType),
},
{
label: treeText("economic.invoice_number", "E-conomic nr."),
value: node.meta?.economicInvoiceId || "-",
},
{
label: treeText("economic.internal_total", "Intern total"),
value: formatCurrency(internalTotals.net_total || 0),
},
{
label: treeText("economic.economic_total", "E-conomic total"),
value: formatCurrency(targetTotals.net_total || 0),
},
{
label: treeText("economic.lines", "Linjer"),
value: targetTotals.line_count ?? "-",
},
];
};
const economicPreviewWarnings = (node: TreeNode) => {
const warnings = economicDetailsForNode(node)?.warnings;
const filteredWarnings = Array.isArray(warnings) ? warnings : [];
return (
node.meta?.economicType === "booked"
? filteredWarnings.filter((warning: any) => !/draft|kladde/i.test(String(warning)))
: filteredWarnings
).slice(0, 4);
};
const downloadAttachmentNode = async (node: TreeNode) => {
const result = await SessionUser.objects.orders.functions.fetchAttachmentContent(
node.meta.orderId,
node.meta.attachmentId,
"attachment"
);
const blob = result instanceof Blob ? result : result?.data;
if (!(blob instanceof Blob)) {
throw new Error(treeText("errors.invalid_attachment", "Vedhæftningen kunne ikke indlæses."));
}
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = String(node.label || `attachment-${node.meta.attachmentId}`);
anchor.rel = "noopener";
anchor.click();
window.setTimeout(() => releaseObjectUrl(url), 0);
};
const downloadEconomicInvoiceNode = async (node: TreeNode) => {
try {
const economicType = node.meta?.economicType === "draft" ? "draft" : "booked";
const response = await SessionUser.objects.collectedOrderInvoices.functions.economic.pdf(
node.meta.collectionId,
economicType
);
openUrlInNewTab(getApiPayload(response)?.url);
} catch (error) {
await Swal.fire({
title: treeText("errors.download_failed", "Download mislykkedes"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
icon: "error",
});
}
};
const onTreeNodeClick = async (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.ATTACHMENT) {
await downloadAttachmentNode(node);
}
if (node.type === TREE_NODE_TYPES.ECONOMIC_INVOICE) {
await downloadEconomicInvoiceNode(node);
}
};
const valueIsEmpty = (value: any) => value === null || value === undefined || value === "";
const formatFieldValue = (value: any, formatter: ((_value: any) => string) | null = null) => {
if (valueIsEmpty(value)) {
return treeText("fields.empty", "Tom");
}
return formatter ? formatter(value) : String(value);
};
const fieldLabel = (columns: Record<string, any>, column: string, fallback: string) =>
columns?.[column]?.label || fallback;
const canEditCollectionField = () => SessionUser.canAccessSuperUser?.() || SessionUser.canAccessAdmin?.();
const canEditOrderField = (order: any, column: string) => {
if (column === "notes") {
return SessionUser.canAccessAdmin?.() === true;
}
return SessionUser.canAccessAdmin?.() === true || SessionUser.canAccessDepartment?.(order?.department_id) === true;
};
const canEditOrderItemField = () => SessionUser.hasPermission?.("edit_order_items") === true;
const refreshAfterInlineEdit = async () => {
emit("refresh");
};
const collectionObjectForNode = (node: TreeNode) => {
const invoiceCollection =
node.meta?.collectionSummary ||
(node.meta?.orders || []).find((order: any) => order?.invoice_collection)?.invoice_collection ||
{};
return {
id: node.meta.collectionId,
customer_number: node.meta.customerNumber,
name: invoiceCollection.name ?? node.meta.customerName,
notes: invoiceCollection.notes ?? "",
po_number: invoiceCollection.po_number ?? "",
external_id: invoiceCollection.external_id ?? "",
closed_at: invoiceCollection.closed_at ?? "",
state: invoiceCollection.state ?? invoiceCollection.invoice_state ?? node.meta.invoiceState ?? "",
error_message: invoiceCollection.error_message ?? invoiceCollection.error ?? "",
booked_invoice_id: invoiceCollection.booked_invoice_id ?? invoiceCollection.booked_id ?? "",
processor: invoiceCollection.processor ?? invoiceCollection.processor_name ?? "",
created_at: invoiceCollection.created_at ?? "",
updated_at: invoiceCollection.updated_at ?? "",
total_net_amount: node.meta.totalNetAmount,
};
};
const orderItemObjectForNode = (node: TreeNode) => ({
...node.meta.item,
quantity: node.meta.item?.quantity ?? node.meta.item?.amount ?? node.meta.quantity,
price: node.meta.item?.price ?? 0,
reference: node.meta.item?.reference ?? "",
notes: node.meta.item?.notes ?? "",
});
const findCachedOrderItem = (itemId: number) => {
for (const rows of Object.values(orderItemRowsByOrderId.value)) {
const match = rows.find((row: any) => toPositiveInteger(row?.id ?? row?.order_item_id) === itemId);
if (match) {
return match;
}
}
return null;
};
const showEditOrderItemFieldForm = async (id: number, column: string, value: any, onAfterSubmit: any = null) => {
const result = await Swal.fire({
title: fieldLabel(
{
quantity: { label: treeText("fields.quantity", "Antal") },
price: { label: treeText("fields.price", "Pris") },
reference: { label: treeText("fields.reference", "Reference") },
notes: { label: treeText("fields.notes", "Noter") },
},
column,
column
),
input: column === "price" || column === "quantity" ? "number" : "text",
inputValue: value ?? "",
showCancelButton: true,
confirmButtonText: commonText("save", "Gem"),
cancelButtonText: commonText("cancel", "Annuller"),
});
if (!result.isConfirmed) {
return result;
}
const item = findCachedOrderItem(toPositiveInteger(id) || 0);
const nextItem = {
...(item || {}),
[column]: result.value,
};
await SessionUser.request("/order/items", "PUT", {
id,
price: Number(nextItem.price ?? 0),
notes: String(nextItem.notes ?? ""),
reference: String(nextItem.reference ?? ""),
quantity: Number(nextItem.quantity ?? nextItem.amount ?? 1),
});
if (item) {
item[column] = result.value;
}
if (typeof onAfterSubmit === "function") {
await onAfterSubmit();
}
return result;
};
type NodeField = {
key: string;
label: string;
value: any;
display: string;
object?: any;
column?: string;
editable?: boolean;
editFunction?: any;
formatter?: ((_value: any) => string) | null;
slot?: number;
span?: number;
row?: number;
numeric?: boolean;
lines?: NodeField[];
};
const makeNodeField = ({
key,
label,
value,
object = null,
column = null,
editable = false,
editFunction = null,
formatter = null,
slot = 1,
span = 1,
row = undefined,
numeric = false,
lines = undefined,
}: Partial<NodeField> & { key: string; label: string; value: any }): NodeField => ({
key,
label,
value,
display: formatFieldValue(value, formatter),
object,
column,
editable,
editFunction,
formatter,
slot,
span,
row,
numeric,
lines,
});
const registrationField = (object: any, columns: Record<string, any>): NodeField => {
const registrationColumns = ["reg_1", "reg_2"];
if (!valueIsEmpty(object.reg_3)) {
registrationColumns.push("reg_3");
}
const lines = registrationColumns.map((column) =>
makeNodeField({
key: column,
label: fieldLabel(columns, column, column.replace("reg_", "Reg. ")),
value: object[column],
object,
column,
editable: canEditOrderField(object, column),
editFunction: SessionUser.objects.orders.showEditObjectFieldForm,
})
);
return makeNodeField({
key: "registrations",
label: treeText("fields.registration_number", "Registreringsnummer"),
value: lines.map((line) => line.value).join("\n"),
slot: 4,
span: 2,
lines,
});
};
const getNodeFields = (node: TreeNode): NodeField[] => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
const object = collectionObjectForNode(node);
const columns = SessionUser.objects.collectedOrderInvoices.columns;
return [
makeNodeField({
key: "po_number",
label: fieldLabel(columns, "po_number", "PO"),
value: object.po_number,
object,
column: "po_number",
editable: canEditCollectionField(),
editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm,
slot: 2,
}),
makeNodeField({
key: "notes",
label: fieldLabel(columns, "notes", "Noter"),
value: object.notes,
object,
column: "notes",
editable: canEditCollectionField(),
editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm,
slot: 3,
}),
makeNodeField({
key: "external_id",
label: fieldLabel(columns, "external_id", "Eksternt ID"),
value: object.external_id,
object,
column: "external_id",
editable: false,
slot: 5,
}),
makeNodeField({
key: "closed_at",
label: fieldLabel(columns, "closed_at", "Lukket"),
value: object.closed_at,
object,
column: "closed_at",
editable: canEditCollectionField(),
editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm,
slot: 6,
}),
makeNodeField({
key: "state",
label: treeText("fields.collection_state", "Status"),
value: object.state,
editable: false,
slot: 1,
}),
makeNodeField({
key: "booked_invoice_id",
label: treeText("fields.booked_invoice_id", "Bogført faktura-ID"),
value: object.booked_invoice_id,
editable: false,
slot: 4,
}),
makeNodeField({
key: "total_net_amount",
label: fieldLabel(columns, "total_net_amount", "Total"),
value: object.total_net_amount,
formatter: formatCurrency,
slot: 8,
numeric: true,
}),
makeNodeField({
key: "processor",
label: treeText("fields.processor", "Behandler"),
value: object.processor,
editable: false,
slot: 7,
}),
makeNodeField({
key: "error_message",
label: treeText("fields.error_message", "Fejl"),
value: object.error_message,
editable: false,
slot: 1,
span: 4,
row: 2,
}),
makeNodeField({
key: "created_at",
label: treeText("fields.created_at", "Oprettet"),
value: object.created_at,
editable: false,
slot: 5,
row: 2,
}),
makeNodeField({
key: "updated_at",
label: treeText("fields.updated_at", "Opdateret"),
value: object.updated_at,
editable: false,
slot: 6,
span: 3,
row: 2,
}),
];
}
if (node.type === TREE_NODE_TYPES.ORDER) {
const object = node.meta.order || {};
const columns = SessionUser.objects.orders.columns;
return [
makeNodeField({
key: "reference",
label: fieldLabel(columns, "reference", "Reference"),
value: object.reference,
object,
column: "reference",
editable: canEditOrderField(object, "reference"),
editFunction: SessionUser.objects.orders.showEditObjectFieldForm,
slot: 1,
}),
makeNodeField({
key: "po",
label: fieldLabel(columns, "po", "PO"),
value: object.po,
object,
column: "po",
editable: canEditOrderField(object, "po"),
editFunction: SessionUser.objects.orders.showEditObjectFieldForm,
slot: 2,
}),
makeNodeField({
key: "notes",
label: fieldLabel(columns, "notes", "Noter"),
value: object.notes,
object,
column: "notes",
editable: canEditOrderField(object, "notes"),
editFunction: SessionUser.objects.orders.showEditObjectFieldForm,
slot: 3,
}),
registrationField(object, columns),
makeNodeField({
key: "include_in_invoice",
label: fieldLabel(columns, "include_in_invoice", "Faktura"),
value: object.include_in_invoice,
object,
column: "include_in_invoice",
editable: canEditOrderField(object, "include_in_invoice"),
editFunction: SessionUser.objects.orders.showEditObjectFieldForm,
slot: 6,
}),
makeNodeField({
key: "total_net_amount",
label: fieldLabel(columns, "total_net_amount", "Total"),
value: object.total_net_amount ?? node.meta.totalNetAmount,
formatter: formatCurrency,
slot: 8,
numeric: true,
}),
];
}
if (node.type === TREE_NODE_TYPES.ORDER_ITEM) {
const object = orderItemObjectForNode(node);
return [
makeNodeField({
key: "reference",
label: treeText("fields.reference", "Reference"),
value: object.reference,
object,
column: "reference",
editable: canEditOrderItemField(),
editFunction: showEditOrderItemFieldForm,
slot: 1,
}),
makeNodeField({
key: "notes",
label: treeText("fields.notes", "Noter"),
value: object.notes,
object,
column: "notes",
editable: canEditOrderItemField(),
editFunction: showEditOrderItemFieldForm,
slot: 3,
}),
makeNodeField({
key: "quantity",
label: treeText("fields.quantity", "Antal"),
value: object.quantity,
object,
column: "quantity",
editable: canEditOrderItemField(),
editFunction: showEditOrderItemFieldForm,
slot: 7,
numeric: true,
}),
makeNodeField({
key: "price",
label: treeText("fields.price", "Pris"),
value: object.price,
object,
column: "price",
editable: canEditOrderItemField(),
editFunction: showEditOrderItemFieldForm,
formatter: formatCurrency,
slot: 8,
numeric: true,
}),
];
}
return [];
};
const visibleNodeFields = (node: TreeNode) => getNodeFields(node);
const fieldEditFunction = (field: NodeField) => (field.editable ? field.editFunction : null);
const fieldParseFunction = (field: NodeField) => (value: any) => formatFieldValue(value, field.formatter || null);
const shouldRenderCollectionRangeLabel = (node: TreeNode) =>
node.type === TREE_NODE_TYPES.COLLECTION && node.meta?.relativeLabel?.kind === "range";
const selectedCountLabel = computed(() => {
if (!hasSelection.value) {
return "";
}
return selectedTypes.value.map((type) => `${selectedByType.value[type].length} ${typeLabel(type)}`).join(", ");
});
const typeLabel = (type: string) =>
({
[TREE_NODE_TYPES.COLLECTION]: treeText("types.collection", "fakturasamlinger"),
[TREE_NODE_TYPES.ORDER]: treeText("types.order", "orders"),
[TREE_NODE_TYPES.ORDER_ITEM]: treeText("types.order_item", "orderlinjer"),
[TREE_NODE_TYPES.ATTACHMENT]: treeText("types.attachment", "vedhæftninger"),
[TREE_NODE_TYPES.BOOKING]: treeText("types.booking", "bookinger"),
[TREE_NODE_TYPES.XLVASK_WASH]: treeText("types.xlvask", "selvvaske"),
}[type] || type);
type TreeAction = {
key: string;
label: string;
icon: string;
tone?: "dark" | "danger" | "default";
affectedCount: number;
disabled?: boolean;
run: () => Promise<void> | void;
};
type TreeActionGroup = {
type: string;
label: string;
count: number;
actions: TreeAction[];
};
type TreeActionRunOptions = {
clearSelection?: boolean;
};
const runActionWithPreview = async (
title: string,
lines: string[],
apply: () => Promise<void>,
destructive = true,
options: TreeActionRunOptions = {}
) => {
const confirmationPhrase = treeText("confirmation.phrase", "Bekræft");
const result = await Swal.fire({
title,
html: `<div class="has-text-left">${lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")}</div>`,
icon: destructive ? "warning" : "question",
input: destructive ? "text" : undefined,
inputLabel: destructive
? treeText("confirmation.input_label", `Skriv ${confirmationPhrase} for at fortsætte`, {
phrase: confirmationPhrase,
})
: undefined,
showCancelButton: true,
confirmButtonText: destructive ? treeText("actions.apply", "Udfør") : commonText("continue", "Fortsæt"),
cancelButtonText: commonText("cancel", "Annuller"),
preConfirm: destructive
? (value) => {
if (String(value || "").trim() !== confirmationPhrase) {
Swal.showValidationMessage(
treeText("confirmation.validation", `Skriv ${confirmationPhrase}`, { phrase: confirmationPhrase })
);
return false;
}
return true;
}
: undefined,
});
if (!result.isConfirmed) {
return;
}
try {
await apply();
if (options.clearSelection !== false) {
checkedKeys.value = [];
}
await Swal.fire({
title: treeText("success.title", "Handling udført"),
icon: "success",
timer: 1400,
showConfirmButton: false,
});
emit("refresh");
} catch (error) {
await Swal.fire({
title: treeText("errors.action_failed", "Handlingen mislykkedes"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
icon: "error",
});
}
};
const escapeHtml = (value: any) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const getApiPayload = (response: any) => response?.data?.data ?? response?.data ?? response ?? {};
const getBulkActionLabel = (action: string) =>
({
[INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC]: t(
"invoicing_period.invoice_collection_actions.actions.queue_economic"
),
[INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES]: t(
"invoicing_period.invoice_collection_actions.actions.remove_customer_rule_violations"
),
[INVOICE_COLLECTION_BULK_ACTIONS.MERGE]: t("invoicing_period.invoice_collection_actions.actions.merge_collections"),
[INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH]: t(
"invoicing_period.invoice_collection_actions.actions.split_by_month"
),
[INVOICE_COLLECTION_BULK_ACTIONS.RESET_HIDDEN_PRICES]: t(
"invoicing_period.invoice_collection_actions.actions.reset_hidden_item_prices"
),
}[action] || action);
const normalizeInvoiceCollectionId = (value: any) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const renderBulkActionPreviewHtml = (preview: any) => {
const summary = preview?.summary || {};
const blockers = Array.isArray(preview?.blockers) ? preview.blockers : [];
const examples = Array.isArray(preview?.examples) ? preview.examples : [];
const lines = [
t("invoicing_period.invoice_collection_actions.preview.collections", {
count: summary.collection_count ?? collectionIds().length,
}),
t("invoicing_period.invoice_collection_actions.preview.changed", {
count: summary.changed_count ?? 0,
}),
t("invoicing_period.invoice_collection_actions.preview.skipped", {
count: summary.skipped_count ?? 0,
}),
];
if (preview?.options?.target_invoice_collection_id) {
lines.push(
t("invoicing_period.invoice_collection_actions.preview.merge_target", {
id: preview.options.target_invoice_collection_id,
})
);
}
if (examples.length > 0) {
lines.push(t("invoicing_period.invoice_collection_actions.preview.affected_examples"));
examples.slice(0, 8).forEach((example: any) => {
lines.push(example.message || JSON.stringify(example));
});
}
if (blockers.length > 0) {
lines.push(t("invoicing_period.invoice_collection_actions.preview.blockers"));
blockers.forEach((blocker: any) => {
lines.push(blocker.message || blocker.code);
});
}
return `<div class="has-text-left">${lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")}</div>`;
};
const chooseMergeTargetInvoiceCollection = async (invoiceCollectionIds: number[]) => {
const result = await Swal.fire({
title: t("invoicing_period.invoice_collection_actions.merge_target_title"),
input: "select",
inputOptions: invoiceCollectionIds.reduce(
(options: Record<string, string>, invoiceCollectionId) => ({
...options,
[invoiceCollectionId]: `#${invoiceCollectionId}`,
}),
{}
),
inputValue: String(invoiceCollectionIds[0] ?? ""),
showCancelButton: true,
confirmButtonText: commonText("continue", "Fortsæt"),
cancelButtonText: commonText("cancel", "Annuller"),
});
if (!result.isConfirmed) {
return null;
}
return normalizeInvoiceCollectionId(result.value);
};
const runCollectionBulkAction = async (
action: string,
ids: any[] = collectionIds(),
runOptions: TreeActionRunOptions = {}
) => {
if (ids.length === 0) {
return;
}
const options: Record<string, any> = {};
if (action === INVOICE_COLLECTION_BULK_ACTIONS.MERGE) {
if (ids.length < 2) {
await Swal.fire({
icon: "warning",
title: t("invoicing_period.invoice_collection_actions.merge_requires_multiple_title"),
text: t("invoicing_period.invoice_collection_actions.merge_requires_multiple_text"),
});
return;
}
const targetInvoiceCollectionId = await chooseMergeTargetInvoiceCollection(ids);
if (targetInvoiceCollectionId === null) {
return;
}
options.target_invoice_collection_id = targetInvoiceCollectionId;
}
try {
const previewResponse = await SessionUser.objects.collectedOrderInvoices.functions.bulk_action_preview(
action,
ids,
options,
localeValue()
);
const preview = getApiPayload(previewResponse);
const blockers = Array.isArray(preview.blockers) ? preview.blockers : [];
const changedCount = Number(preview?.summary?.changed_count ?? 0);
if (blockers.length > 0 || changedCount === 0) {
await Swal.fire({
icon: blockers.length > 0 ? "error" : "info",
title:
blockers.length > 0
? t("invoicing_period.invoice_collection_actions.preview.blocked_title")
: t("invoicing_period.invoice_collection_actions.preview.no_changes_title"),
html: renderBulkActionPreviewHtml(preview),
});
return;
}
const confirmation = await Swal.fire({
icon: "warning",
title: t("invoicing_period.invoice_collection_actions.preview.title", {
action: getBulkActionLabel(action),
}),
html: renderBulkActionPreviewHtml(preview),
input: "text",
inputLabel: t("invoicing_period.invoice_collection_actions.preview.confirmation_label", {
phrase: preview.confirmation_phrase,
}),
inputPlaceholder: preview.confirmation_phrase,
showCancelButton: true,
confirmButtonText: t("invoicing_period.invoice_collection_actions.preview.confirm_button"),
cancelButtonText: commonText("cancel", "Annuller"),
inputValidator: (value) => {
if (String(value ?? "").trim() !== String(preview.confirmation_phrase ?? "")) {
return t("invoicing_period.invoice_collection_actions.preview.confirmation_mismatch", {
phrase: preview.confirmation_phrase,
});
}
return undefined;
},
});
if (!confirmation.isConfirmed) {
return;
}
const applyResponse = await SessionUser.objects.collectedOrderInvoices.functions.bulk_action_apply({
preview_id: preview.preview_id,
action,
invoice_collection_ids: ids,
options,
confirmation_text: confirmation.value,
locale: localeValue(),
});
const applied = getApiPayload(applyResponse);
if (action === INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC) {
const serverQueueJobs = applied?.jobs ?? applied?.result?.jobs;
const serverQueueJobIds = applied?.queue_job_ids ?? applied?.result?.queue_job_ids;
const wasDurablyQueuedByServer =
(Array.isArray(serverQueueJobs) && serverQueueJobs.length > 0) ||
(Array.isArray(serverQueueJobIds) && serverQueueJobIds.length > 0);
if (!wasDurablyQueuedByServer) {
const queuedInvoiceCollectionIds = applied?.result?.queued_invoice_collection_ids || ids;
invoiceQueue.addInvoiceCollectionsToQueue(queuedInvoiceCollectionIds, {
customerNumber: props.customer?.customer_number,
});
invoiceQueue.processInvoiceCollectionQueue();
}
}
if (runOptions.clearSelection !== false) {
checkedKeys.value = [];
}
await Swal.fire({
icon: "success",
title: t("invoicing_period.invoice_collection_actions.success_title"),
text: t("invoicing_period.invoice_collection_actions.success_text", {
count: applied?.result?.changed_count ?? applied?.summary?.changed_count ?? changedCount,
}),
timer: 2000,
showConfirmButton: false,
});
emit("refresh");
} catch (error) {
await Swal.fire({
icon: "error",
title: t("invoicing_period.invoice_collection_actions.error_title"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
});
}
};
const markOrdersCompleted = (nodes: TreeNode[] = orderNodes(), options: TreeActionRunOptions = {}) => {
const ids = orderIdsFromNodes(nodes);
return runActionWithPreview(
treeText("actions.orders.mark_completed.title", "Marker orders som færdige"),
[treeText("actions.orders.mark_completed.preview", "{count} orders markeres som færdige.", { count: ids.length })],
async () => {
for (const orderId of ids) {
await SessionUser.objects.orders.functions.mark_as_completed(orderId);
}
},
true,
options
);
};
const markSelectedOrdersCompleted = () => markOrdersCompleted(orderNodes());
const setOrdersInvoiceIncluded = (
include: boolean | null,
nodes: TreeNode[] = orderNodes(),
options: TreeActionRunOptions = {}
) => {
const ids = orderIdsFromNodes(nodes);
return runActionWithPreview(
include === false
? treeText("actions.orders.exclude_invoice.title", "Ekskluder orders fra faktura")
: treeText("actions.orders.include_invoice.title", "Inkluder orders på faktura"),
[treeText("actions.orders.invoice_override.preview", "{count} orders opdateres.", { count: ids.length })],
async () => {
for (const orderId of ids) {
await SessionUser.objects.orders.set.include_in_invoice(
orderId,
include === null ? null : include ? "include" : "exclude"
);
}
},
true,
options
);
};
const setSelectedOrdersInvoiceIncluded = (include: boolean | null) => setOrdersInvoiceIncluded(include, orderNodes());
const deleteOrders = (nodes: TreeNode[] = orderNodes(), options: TreeActionRunOptions = {}) => {
const ids = orderIdsFromNodes(nodes);
return runActionWithPreview(
treeText("actions.orders.delete.title", "Slet valgte orders"),
[treeText("actions.orders.delete.preview", "{count} orders slettes.", { count: ids.length })],
async () => {
for (const orderId of ids) {
await SessionUser.objects.orders.delete.single(orderId, { confirmed: true });
}
},
true,
options
);
};
const deleteSelectedOrders = () => deleteOrders(orderNodes());
const moveOrdersToInvoiceCollection = async (nodes: TreeNode[] = orderNodes(), options: TreeActionRunOptions = {}) => {
const ids = orderIdsFromNodes(nodes);
if (ids.length === 0) {
return;
}
const result = await Swal.fire({
title: treeText("actions.orders.move_collection.title", "Flyt orders til fakturasamling"),
input: "number",
inputLabel: treeText("actions.orders.move_collection.input_label", "Mål-fakturasamling ID"),
showCancelButton: true,
confirmButtonText: commonText("continue", "Fortsæt"),
cancelButtonText: commonText("cancel", "Annuller"),
inputValidator: (value) =>
normalizeInvoiceCollectionId(value) === null
? treeText("actions.orders.move_collection.validation", "Angiv et gyldigt fakturasamlings-ID.")
: undefined,
});
if (!result.isConfirmed) {
return;
}
const targetInvoiceCollectionId = normalizeInvoiceCollectionId(result.value);
if (targetInvoiceCollectionId === null) {
return;
}
return runActionWithPreview(
treeText("actions.orders.move_collection.title", "Flyt orders til fakturasamling"),
[
treeText("actions.orders.move_collection.preview", "{count} orders flyttes til fakturasamling #{id}.", {
count: ids.length,
id: targetInvoiceCollectionId,
}),
],
async () => {
for (const orderId of ids) {
await SessionUser.objects.orders.set.invoice_collection_id(orderId, targetInvoiceCollectionId);
}
},
true,
options
);
};
const moveSelectedOrdersToInvoiceCollection = () => moveOrdersToInvoiceCollection(orderNodes());
const unlinkOrderBookings = (nodes: TreeNode[] = orderNodes(), options: TreeActionRunOptions = {}) => {
const orders = orderNodesFromNodes(nodes).filter((node) => node.meta.bookingId);
return runActionWithPreview(
treeText("actions.orders.unlink_booking.title", "Fjern booking fra orders"),
[
treeText("actions.orders.unlink_booking.preview", "{count} orders får fjernet booking-link.", {
count: orders.length,
}),
],
async () => {
for (const node of orders) {
await SessionUser.objects.orders.set.booking_id(node.meta.orderId, null);
}
},
true,
options
);
};
const unlinkSelectedOrderBookings = () => unlinkOrderBookings(orderNodes());
const unlinkOrderXlvask = (nodes: TreeNode[] = orderNodes(), options: TreeActionRunOptions = {}) => {
const orders = orderNodesFromNodes(nodes).filter((node) => node.meta.washId);
return runActionWithPreview(
treeText("actions.orders.unlink_xlvask.title", "Fjern Selvvask fra orders"),
[
treeText("actions.orders.unlink_xlvask.preview", "{count} orders får fjernet Selvvask-link.", {
count: orders.length,
}),
],
async () => {
for (const node of orders) {
await SessionUser.objects.orders.set.wash_id(node.meta.orderId, "");
}
},
true,
options
);
};
const unlinkSelectedOrderXlvask = () => unlinkOrderXlvask(orderNodes());
const deleteOrderItems = (ids: any[] = orderItemIds(), options: TreeActionRunOptions = {}) =>
runActionWithPreview(
treeText("actions.order_items.delete.title", "Slet orderlinjer"),
[treeText("actions.order_items.delete.preview", "{count} orderlinjer slettes.", { count: ids.length })],
async () => {
for (const itemId of ids) {
await SessionUser.request("/order/items", "DELETE", { id: itemId });
}
},
true,
options
);
const deleteSelectedOrderItems = () => deleteOrderItems(orderItemIds());
const downloadAttachments = async (nodes: TreeNode[] = attachmentNodes()) => {
for (const node of attachmentNodesFromNodes(nodes)) {
await downloadAttachmentNode(node);
}
};
const downloadSelectedAttachments = () => downloadAttachments(attachmentNodes());
const deleteAttachments = (nodes: TreeNode[] = attachmentNodes(), options: TreeActionRunOptions = {}) => {
const attachments = attachmentNodesFromNodes(nodes);
return runActionWithPreview(
treeText("actions.attachments.delete.title", "Slet vedhæftninger"),
[treeText("actions.attachments.delete.preview", "{count} vedhæftninger slettes.", { count: attachments.length })],
async () => {
for (const node of attachments) {
await SessionUser.objects.orders.functions.removeAttachment(node.meta.orderId, node.meta.attachmentId);
}
},
true,
options
);
};
const deleteSelectedAttachments = () => deleteAttachments(attachmentNodes());
const resendWashCertificates = (
orderIdsToResend: any[] = certificateAttachmentOrderIds.value,
options: TreeActionRunOptions = {}
) =>
runActionWithPreview(
treeText("actions.attachments.resend_wash_certificates.title", "Gensend vaskecertifikater"),
[
treeText("actions.attachments.resend_wash_certificates.preview", "{count} ordre får gensendt vaskecertifikat.", {
count: orderIdsToResend.length,
}),
],
async () => {
for (const orderId of orderIdsToResend) {
await SessionUser.objects.orders.functions.resendWashCertificate(orderId);
}
},
false,
options
);
const resendSelectedWashCertificates = () => resendWashCertificates(certificateAttachmentOrderIds.value);
const resendBookingConfirmations = (ids: any[] = bookingIds(), options: TreeActionRunOptions = {}) =>
runActionWithPreview(
treeText("actions.bookings.resend_confirmation.title", "Gensend bookingbekræftelser"),
[
treeText("actions.bookings.resend_confirmation.preview", "{count} bookingbekræftelser sendes igen.", {
count: ids.length,
}),
],
async () => {
for (const bookingId of ids) {
await SessionUser.objects.order_bookings.functions.resendBookingConfirmation(bookingId);
}
},
false,
options
);
const resendSelectedBookingConfirmations = () => resendBookingConfirmations(bookingIds());
const resendBookingCompletionConfirmations = (ids: any[] = bookingIds(), options: TreeActionRunOptions = {}) =>
runActionWithPreview(
treeText("actions.bookings.resend_completion.title", "Gensend afslutningsbekræftelser"),
[
treeText("actions.bookings.resend_completion.preview", "{count} afslutningsbekræftelser sendes igen.", {
count: ids.length,
}),
],
async () => {
for (const bookingId of ids) {
await SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation(bookingId);
}
},
false,
options
);
const resendSelectedBookingCompletionConfirmations = () => resendBookingCompletionConfirmations(bookingIds());
const deleteBookings = (ids: any[] = bookingIds(), options: TreeActionRunOptions = {}) =>
runActionWithPreview(
treeText("actions.bookings.delete.title", "Slet bookinger"),
[treeText("actions.bookings.delete.preview", "{count} bookinger slettes.", { count: ids.length })],
async () => {
for (const bookingId of ids) {
await SessionUser.objects.order_bookings.delete.single(bookingId);
}
},
true,
options
);
const deleteSelectedBookings = () => deleteBookings(bookingIds());
const ignoreXlvaskRows = (nodes: TreeNode[] = actionableXlvaskNodes.value, options: TreeActionRunOptions = {}) => {
const rows = actionableXlvaskNodesFromNodes(nodes);
return runActionWithPreview(
treeText("actions.xlvask.ignore.title", "Ignorer XL Vask rækker"),
[treeText("actions.xlvask.ignore.preview", "{count} XL Vask rækker ignoreres.", { count: rows.length })],
async () => {
for (const node of rows) {
await SessionUser.request(`/modules/xlvask/services/usage/orders/${node.meta.usageId}/ignore`, "PATCH", {
reason: "Ignored from invoice period tree",
});
}
},
true,
options
);
};
const ignoreSelectedXlvaskRows = () => ignoreXlvaskRows(actionableXlvaskNodes.value);
const decideXlvaskAutomation = (
decision: "accept" | "deny",
nodes: TreeNode[] = actionableXlvaskNodes.value,
options: TreeActionRunOptions = {}
) => {
const rows = actionableXlvaskNodesFromNodes(nodes);
return runActionWithPreview(
decision === "accept"
? treeText("actions.xlvask.accept.title", "Accepter XL Vask forslag")
: treeText("actions.xlvask.deny.title", "Afvis XL Vask forslag"),
[
decision === "accept"
? treeText("actions.xlvask.accept.preview", "{count} XL Vask forslag accepteres.", { count: rows.length })
: treeText("actions.xlvask.deny.preview", "{count} XL Vask forslag afvises.", { count: rows.length }),
],
async () => {
for (const node of rows) {
await SessionUser.request(
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/automation/${decision}`,
"POST",
{
suggestion_id: node.meta.automation?.id ?? null,
reason: `${decision} from invoice period tree`,
}
);
}
},
true,
options
);
};
const decideSelectedXlvaskAutomation = (decision: "accept" | "deny") =>
decideXlvaskAutomation(decision, actionableXlvaskNodes.value);
const collectionActions = (): TreeAction[] => [
{
key: "collection:queue-economic",
label: t("invoicing_period.invoice_collection_actions.actions.queue_economic"),
icon: "fa-file-invoice",
tone: "dark",
affectedCount: selectedCollectionCount.value,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC),
},
{
key: "collection:clean-customer-rules",
label: t("invoicing_period.invoice_collection_actions.actions.remove_customer_rule_violations"),
icon: "fa-broom",
affectedCount: selectedCollectionCount.value,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES),
},
{
key: "collection:merge",
label: t("invoicing_period.invoice_collection_actions.actions.merge_collections"),
icon: "fa-compress-arrows-alt",
affectedCount: selectedCollectionCount.value > 1 ? selectedCollectionCount.value : 0,
disabled: selectedCollectionCount.value < 2,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.MERGE),
},
{
key: "collection:split-by-month",
label: t("invoicing_period.invoice_collection_actions.actions.split_by_month"),
icon: "fa-calendar-alt",
affectedCount: selectedCollectionCount.value,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH),
},
{
key: "collection:reset-hidden-prices",
label: t("invoicing_period.invoice_collection_actions.actions.reset_hidden_item_prices"),
icon: "fa-tags",
affectedCount: selectedCollectionCount.value,
run: () => runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.RESET_HIDDEN_PRICES),
},
];
const orderActions = (): TreeAction[] => [
{
key: "order:mark-completed",
label: treeText("buttons.mark_completed", "Marker færdig"),
icon: "fa-check",
affectedCount: orderIds().length,
run: markSelectedOrdersCompleted,
},
{
key: "order:include-invoice",
label: treeText("buttons.include_invoice", "Inkluder"),
icon: "fa-file-alt",
affectedCount: orderIds().length,
run: () => setSelectedOrdersInvoiceIncluded(true),
},
{
key: "order:exclude-invoice",
label: treeText("buttons.exclude_invoice", "Ekskluder"),
icon: "fa-ban",
affectedCount: orderIds().length,
run: () => setSelectedOrdersInvoiceIncluded(false),
},
{
key: "order:move-collection",
label: treeText("buttons.move_collection", "Flyt samling"),
icon: "fa-arrow-right",
affectedCount: orderIds().length,
run: moveSelectedOrdersToInvoiceCollection,
},
{
key: "order:unlink-booking",
label: treeText("buttons.unlink_booking", "Fjern booking"),
icon: "fa-calendar-times",
affectedCount: selectedOrdersWithBookingCount.value,
disabled: selectedOrdersWithBookingCount.value === 0,
run: unlinkSelectedOrderBookings,
},
{
key: "order:unlink-xlvask",
label: treeText("buttons.unlink_xlvask", "Fjern Selvvask"),
icon: "fa-unlink",
affectedCount: selectedOrdersWithWashCount.value,
disabled: selectedOrdersWithWashCount.value === 0,
run: unlinkSelectedOrderXlvask,
},
{
key: "order:delete",
label: treeText("buttons.delete", "Slet"),
icon: "fa-trash",
tone: "danger",
affectedCount: orderIds().length,
run: deleteSelectedOrders,
},
];
const orderItemActions = (): TreeAction[] => [
{
key: "order-item:delete",
label: treeText("buttons.delete_lines", "Slet linjer"),
icon: "fa-trash",
tone: "danger",
affectedCount: orderItemIds().length,
run: deleteSelectedOrderItems,
},
];
const attachmentActions = (): TreeAction[] => [
{
key: "attachment:download",
label: treeText("buttons.download", "Download"),
icon: "fa-download",
affectedCount: attachmentNodes().length,
run: downloadSelectedAttachments,
},
{
key: "attachment:resend-wash-certificate",
label: treeText("buttons.resend_wash_certificate", "Gensend vaskecertifikat"),
icon: "fa-paper-plane",
affectedCount: certificateAttachmentOrderIds.value.length,
disabled: certificateAttachmentOrderIds.value.length === 0,
run: resendSelectedWashCertificates,
},
{
key: "attachment:delete",
label: treeText("buttons.delete", "Slet"),
icon: "fa-trash",
tone: "danger",
affectedCount: attachmentNodes().length,
run: deleteSelectedAttachments,
},
];
const bookingActions = (): TreeAction[] => [
{
key: "booking:resend-confirmation",
label: treeText("buttons.resend_booking", "Gensend"),
icon: "fa-paper-plane",
affectedCount: bookingIds().length,
run: resendSelectedBookingConfirmations,
},
{
key: "booking:resend-completion-confirmation",
label: treeText("buttons.resend_booking_completion", "Gensend afslutning"),
icon: "fa-envelope-open-text",
affectedCount: bookingIds().length,
run: resendSelectedBookingCompletionConfirmations,
},
{
key: "booking:delete",
label: treeText("buttons.delete", "Slet"),
icon: "fa-trash",
tone: "danger",
affectedCount: bookingIds().length,
run: deleteSelectedBookings,
},
];
const xlvaskActions = (): TreeAction[] => [
{
key: "xlvask:accept",
label: treeText("buttons.accept_xlvask", "Accepter forslag"),
icon: "fa-check",
affectedCount: actionableXlvaskNodes.value.length,
disabled: actionableXlvaskNodes.value.length === 0,
run: () => decideSelectedXlvaskAutomation("accept"),
},
{
key: "xlvask:deny",
label: treeText("buttons.deny_xlvask", "Afvis forslag"),
icon: "fa-times",
affectedCount: actionableXlvaskNodes.value.length,
disabled: actionableXlvaskNodes.value.length === 0,
run: () => decideSelectedXlvaskAutomation("deny"),
},
{
key: "xlvask:ignore",
label: treeText("buttons.ignore", "Ignorer"),
icon: "fa-ban",
tone: "danger",
affectedCount: actionableXlvaskNodes.value.length,
disabled: actionableXlvaskNodes.value.length === 0,
run: ignoreSelectedXlvaskRows,
},
];
const actionsForType = (type: string): TreeAction[] =>
({
[TREE_NODE_TYPES.COLLECTION]: collectionActions,
[TREE_NODE_TYPES.ORDER]: orderActions,
[TREE_NODE_TYPES.ORDER_ITEM]: orderItemActions,
[TREE_NODE_TYPES.ATTACHMENT]: attachmentActions,
[TREE_NODE_TYPES.BOOKING]: bookingActions,
[TREE_NODE_TYPES.XLVASK_WASH]: xlvaskActions,
}[type]?.() || []);
const selectedActionCount = (type: string) =>
({
[TREE_NODE_TYPES.COLLECTION]: collectionIds().length,
[TREE_NODE_TYPES.ORDER]: orderIds().length,
[TREE_NODE_TYPES.ORDER_ITEM]: orderItemIds().length,
[TREE_NODE_TYPES.ATTACHMENT]: attachmentNodes().length,
[TREE_NODE_TYPES.BOOKING]: bookingIds().length,
[TREE_NODE_TYPES.XLVASK_WASH]: xlvaskNodes().length,
}[type] || 0);
const actionGroups = computed<TreeActionGroup[]>(() =>
selectedTypes.value
.map((type) => ({
type,
label: typeLabel(type),
count: selectedActionCount(type),
actions: actionsForType(type),
}))
.filter((group) => group.count > 0 && group.actions.length > 0)
);
const hasActionGroups = computed(() => actionGroups.value.length > 0);
const isTreeActionLoading = (action: TreeAction) => activeTreeActionKey.value === action.key;
const isTreeActionDisabled = (action: TreeAction) =>
Boolean(action.disabled || action.affectedCount === 0 || activeTreeActionKey.value);
const treeActionItemClass = (action: TreeAction) => ({
"has-text-danger": action.tone === "danger",
"has-text-dark": action.tone === "dark",
"is-disabled": isTreeActionDisabled(action),
});
const isActionGroupOpen = (group: TreeActionGroup) => openActionGroupType.value === group.type;
const toggleActionsMenu = (group: TreeActionGroup) => {
if (activeTreeActionKey.value || !hasActionGroups.value) {
return;
}
openActionGroupType.value = isActionGroupOpen(group) ? null : group.type;
};
const runTreeAction = async (action: TreeAction) => {
if (isTreeActionDisabled(action)) {
return;
}
openActionGroupType.value = null;
activeTreeActionKey.value = action.key;
try {
await Promise.resolve(action.run());
} finally {
activeTreeActionKey.value = null;
}
};
type WheelMenuItem = {
key: string;
icon: string;
label: string;
detailValue?: string | number;
template?: string;
disabled?: boolean;
testId?: string;
clickAction: () => Promise<void> | void;
};
type WheelMenuSection = {
key: string;
label: string;
items: WheelMenuItem[];
};
const rowActionOptions = (): TreeActionRunOptions => ({ clearSelection: false });
const rowActionKey = (node: TreeNode, key: string) => `row:${node.id}:${key}`;
const rowAction = (node: TreeNode, key: string, action: Omit<TreeAction, "key">): TreeAction => ({
key: rowActionKey(node, key),
...action,
});
const wheelActionIcon = (icon: string) => (icon.includes(" ") ? icon : `fas ${icon}`);
const wheelActionTemplate = (action: TreeAction) => {
if (action.tone === "danger") {
return "danger";
}
if (action.tone === "dark") {
return "default";
}
return "light";
};
const treeActionToWheelItem = (action: TreeAction): WheelMenuItem => ({
key: action.key,
icon: wheelActionIcon(action.icon),
label: action.label,
detailValue: action.affectedCount > 1 ? action.affectedCount : undefined,
template: wheelActionTemplate(action),
disabled: isTreeActionDisabled(action),
testId: `invoice-period-tree-node-action-${action.key}`,
clickAction: () => runTreeAction(action),
});
const collectionNodeActions = (node: TreeNode): TreeAction[] => {
const collectionId = toPositiveInteger(node.meta?.collectionId);
if (!collectionId) {
return [];
}
return [
rowAction(node, "collection:queue-economic", {
label: t("invoicing_period.invoice_collection_actions.actions.queue_economic"),
icon: "fa-file-invoice",
tone: "dark",
affectedCount: 1,
run: () =>
runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC, [collectionId], rowActionOptions()),
}),
rowAction(node, "collection:clean-customer-rules", {
label: t("invoicing_period.invoice_collection_actions.actions.remove_customer_rule_violations"),
icon: "fa-broom",
affectedCount: 1,
run: () =>
runCollectionBulkAction(
INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES,
[collectionId],
rowActionOptions()
),
}),
rowAction(node, "collection:split-by-month", {
label: t("invoicing_period.invoice_collection_actions.actions.split_by_month"),
icon: "fa-calendar-alt",
affectedCount: 1,
run: () =>
runCollectionBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH, [collectionId], rowActionOptions()),
}),
rowAction(node, "collection:reset-hidden-prices", {
label: t("invoicing_period.invoice_collection_actions.actions.reset_hidden_item_prices"),
icon: "fa-tags",
affectedCount: 1,
run: () =>
runCollectionBulkAction(
INVOICE_COLLECTION_BULK_ACTIONS.RESET_HIDDEN_PRICES,
[collectionId],
rowActionOptions()
),
}),
];
};
const orderNodeActions = (node: TreeNode): TreeAction[] => {
const nodes = [node];
const hasBooking = Boolean(node.meta?.bookingId);
const hasWash = Boolean(node.meta?.washId);
return [
rowAction(node, "order:mark-completed", {
label: treeText("buttons.mark_completed", "Marker færdig"),
icon: "fa-check",
affectedCount: 1,
run: () => markOrdersCompleted(nodes, rowActionOptions()),
}),
rowAction(node, "order:include-invoice", {
label: treeText("buttons.include_invoice", "Inkluder"),
icon: "fa-file-alt",
affectedCount: 1,
run: () => setOrdersInvoiceIncluded(true, nodes, rowActionOptions()),
}),
rowAction(node, "order:exclude-invoice", {
label: treeText("buttons.exclude_invoice", "Ekskluder"),
icon: "fa-ban",
affectedCount: 1,
run: () => setOrdersInvoiceIncluded(false, nodes, rowActionOptions()),
}),
hasBooking
? rowAction(node, "order:unlink-booking", {
label: treeText("buttons.unlink_booking", "Fjern booking"),
icon: "fa-calendar-times",
affectedCount: 1,
run: () => unlinkOrderBookings(nodes, rowActionOptions()),
})
: null,
hasWash
? rowAction(node, "order:unlink-xlvask", {
label: treeText("buttons.unlink_xlvask", "Fjern Selvvask"),
icon: "fa-unlink",
affectedCount: 1,
run: () => unlinkOrderXlvask(nodes, rowActionOptions()),
})
: null,
].filter(Boolean) as TreeAction[];
};
const orderItemNodeActions = (node: TreeNode): TreeAction[] => {
const itemId = toPositiveInteger(node.meta?.itemId);
if (!itemId) {
return [];
}
return [
rowAction(node, "order-item:delete", {
label: treeText("buttons.delete_lines", "Slet linjer"),
icon: "fa-trash",
tone: "danger",
affectedCount: 1,
run: () => deleteOrderItems([itemId], rowActionOptions()),
}),
];
};
const attachmentNodeActions = (node: TreeNode): TreeAction[] => {
const nodes = [node];
const orderId = toPositiveInteger(node.meta?.orderId);
return [
rowAction(node, "attachment:download", {
label: treeText("buttons.download", "Download"),
icon: "fa-download",
affectedCount: 1,
run: () => downloadAttachments(nodes),
}),
node.meta?.attachmentType === "certificate" && orderId
? rowAction(node, "attachment:resend-wash-certificate", {
label: treeText("buttons.resend_wash_certificate", "Gensend vaskecertifikat"),
icon: "fa-paper-plane",
affectedCount: 1,
run: () => resendWashCertificates([orderId], rowActionOptions()),
})
: null,
rowAction(node, "attachment:delete", {
label: treeText("buttons.delete", "Slet"),
icon: "fa-trash",
tone: "danger",
affectedCount: 1,
run: () => deleteAttachments(nodes, rowActionOptions()),
}),
].filter(Boolean) as TreeAction[];
};
const bookingNodeActions = (node: TreeNode): TreeAction[] => {
const bookingId = toPositiveInteger(node.meta?.bookingId);
if (!bookingId) {
return [];
}
return [
rowAction(node, "booking:resend-confirmation", {
label: treeText("buttons.resend_booking", "Gensend"),
icon: "fa-paper-plane",
affectedCount: 1,
run: () => resendBookingConfirmations([bookingId], rowActionOptions()),
}),
rowAction(node, "booking:resend-completion-confirmation", {
label: treeText("buttons.resend_booking_completion", "Gensend afslutning"),
icon: "fa-envelope-open-text",
affectedCount: 1,
run: () => resendBookingCompletionConfirmations([bookingId], rowActionOptions()),
}),
rowAction(node, "booking:delete", {
label: treeText("buttons.delete", "Slet"),
icon: "fa-trash",
tone: "danger",
affectedCount: 1,
run: () => deleteBookings([bookingId], rowActionOptions()),
}),
];
};
const xlvaskNodeActions = (node: TreeNode): TreeAction[] => {
if (!node.meta?.usageId) {
return [];
}
const nodes = [node];
return [
rowAction(node, "xlvask:accept", {
label: treeText("buttons.accept_xlvask", "Accepter forslag"),
icon: "fa-check",
affectedCount: 1,
run: () => decideXlvaskAutomation("accept", nodes, rowActionOptions()),
}),
rowAction(node, "xlvask:deny", {
label: treeText("buttons.deny_xlvask", "Afvis forslag"),
icon: "fa-times",
affectedCount: 1,
run: () => decideXlvaskAutomation("deny", nodes, rowActionOptions()),
}),
rowAction(node, "xlvask:ignore", {
label: treeText("buttons.ignore", "Ignorer"),
icon: "fa-ban",
tone: "danger",
affectedCount: 1,
run: () => ignoreXlvaskRows(nodes, rowActionOptions()),
}),
];
};
const economicInvoiceNodeActions = (node: TreeNode): TreeAction[] => [
rowAction(node, "economic-invoice:download", {
label: treeText("buttons.download", "Download"),
icon: "fa-download",
affectedCount: 1,
run: () => downloadEconomicInvoiceNode(node),
}),
];
const actionsForNode = (node: TreeNode): TreeAction[] =>
({
[TREE_NODE_TYPES.COLLECTION]: collectionNodeActions,
[TREE_NODE_TYPES.ORDER]: orderNodeActions,
[TREE_NODE_TYPES.ORDER_ITEM]: orderItemNodeActions,
[TREE_NODE_TYPES.ATTACHMENT]: attachmentNodeActions,
[TREE_NODE_TYPES.BOOKING]: bookingNodeActions,
[TREE_NODE_TYPES.XLVASK_WASH]: xlvaskNodeActions,
[TREE_NODE_TYPES.ECONOMIC_INVOICE]: economicInvoiceNodeActions,
}[node.type]?.(node) || []);
const nodeActionSections = (node: TreeNode): WheelMenuSection[] => {
const items = actionsForNode(node).map(treeActionToWheelItem);
if (items.length === 0) {
return [];
}
return [
{
key: `node-actions:${node.id}`,
label: typeLabel(node.type),
items,
},
];
};
const nodeActionWheelProps = (node: TreeNode) => {
const order = node.meta?.order || {};
if (node.type === TREE_NODE_TYPES.COLLECTION) {
return {
invoice_collection_id: toPositiveInteger(node.meta?.collectionId),
customer_number: toPositiveInteger(node.meta?.customerNumber ?? props.customer?.customer_number),
refreshFunction: refreshAfterInlineEdit,
flagTarget: nodeFlagTarget(node)
? { target_type: nodeFlagTarget(node)?.type, target_id: nodeFlagTarget(node)?.id }
: null,
};
}
if (node.type === TREE_NODE_TYPES.ORDER) {
return {
order_id: toPositiveInteger(node.meta?.orderId),
department_id: toPositiveInteger(order.department_id ?? node.meta?.departmentId),
invoice_collection_id: toPositiveInteger(order.invoice_collection_id ?? node.meta?.collectionId),
order_booking_id: toPositiveInteger(order.booking_id ?? node.meta?.bookingId),
customer_number: toPositiveInteger(order.customer_number ?? props.customer?.customer_number),
reg_1: order.reg_1 ?? null,
reg_2: order.reg_2 ?? null,
reg_3: order.reg_3 ?? null,
attachments: Array.isArray(order.attachments) ? order.attachments : [],
refreshFunction: refreshAfterInlineEdit,
flagTarget: nodeFlagTarget(node)
? { target_type: nodeFlagTarget(node)?.type, target_id: nodeFlagTarget(node)?.id }
: null,
};
}
if (node.type === TREE_NODE_TYPES.BOOKING) {
return {
order_booking_id: toPositiveInteger(node.meta?.bookingId),
order_id: toPositiveInteger(node.meta?.orderId),
refreshFunction: refreshAfterInlineEdit,
flagTarget: nodeFlagTarget(node)
? { target_type: nodeFlagTarget(node)?.type, target_id: nodeFlagTarget(node)?.id }
: null,
};
}
return {
refreshFunction: refreshAfterInlineEdit,
flagTarget: nodeFlagTarget(node)
? { target_type: nodeFlagTarget(node)?.type, target_id: nodeFlagTarget(node)?.id }
: null,
};
};
</script>
<template>
<div class="invoice-period-object-tree" data-testid="invoice-period-object-tree">
<div class="invoice-period-tree-toolbar" data-testid="invoice-period-tree-toolbar">
<div class="invoice-period-tree-toolbar__controls">
<button
type="button"
class="button is-small is-light invoice-period-tree-toggle-all"
:class="{ 'is-loading': isExpandingAll }"
:disabled="isExpandingAll || !hasExpandableNodes"
:aria-pressed="isAllExpanded"
aria-controls="invoice-period-object-tree-root"
data-testid="invoice-period-tree-toggle-all"
@click="toggleExpandAll"
>
<span class="icon is-small" aria-hidden="true">
<i class="fas" :class="isAllExpanded ? 'fa-compress-alt' : 'fa-expand-alt'"></i>
</span>
<span>
{{
isAllExpanded
? treeText("buttons.collapse_all", "Fold alle sammen")
: treeText("buttons.expand_all", "Udfold alle")
}}
</span>
</button>
<span v-if="hasSelection" class="tag is-dark is-light">{{ selectedCountLabel }}</span>
</div>
<div v-if="hasSelection" class="invoice-period-tree-toolbar__actions">
<div
v-for="group in actionGroups"
:key="group.type"
class="dropdown is-right invoice-period-tree-actions-dropdown"
:class="{ 'is-active': isActionGroupOpen(group) }"
:data-testid="`invoice-period-tree-actions-dropdown-${group.type}`"
>
<div class="dropdown-trigger">
<button
type="button"
class="button is-small is-dark"
:class="{ 'is-loading': activeTreeActionKey && isActionGroupOpen(group) }"
:disabled="Boolean(activeTreeActionKey)"
aria-haspopup="true"
:aria-controls="`invoice-period-tree-actions-menu-${group.type}`"
:data-testid="`invoice-period-tree-actions-trigger-${group.type}`"
@click.stop="toggleActionsMenu(group)"
>
<span>{{ group.label }}</span>
<span class="tag is-dark is-small invoice-period-tree-actions-trigger-count">{{ group.count }}</span>
<span class="icon is-small"><i class="fas fa-chevron-down"></i></span>
</button>
</div>
<div :id="`invoice-period-tree-actions-menu-${group.type}`" class="dropdown-menu" role="menu">
<div class="dropdown-content invoice-period-tree-actions-menu">
<button
v-for="action in group.actions"
:key="action.key"
type="button"
class="dropdown-item invoice-period-tree-action"
:class="treeActionItemClass(action)"
:disabled="isTreeActionDisabled(action)"
:data-testid="`invoice-period-tree-action-${action.key}`"
@click.stop="runTreeAction(action)"
>
<span class="invoice-period-tree-action__main">
<span class="icon is-small">
<i v-if="isTreeActionLoading(action)" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas" :class="action.icon"></i>
</span>
<span>{{ action.label }}</span>
</span>
<span class="tag is-light is-small invoice-period-tree-action__count">
{{ action.affectedCount }}
</span>
</button>
</div>
</div>
</div>
<span v-if="!hasActionGroups" class="tag is-warning is-light">{{
treeText("selection.no_actions", "Ingen handlinger til de valgte objekter")
}}</span>
</div>
</div>
<BuefyTree
:key="currentSignature"
ref="treeRef"
id="invoice-period-object-tree-root"
v-model:checked-keys="checkedKeys"
v-model:expanded-keys="expandedKeys"
:data="rootNodes"
:fields="{ id: 'id', label: 'label', children: 'children', isLeaf: 'isLeaf', disabled: 'disabled' }"
selection-mode="checkbox"
:lazy="true"
:load="loadTreeNodeChildren"
:progressive-batch-size="50"
:load-more-label="treeText('buttons.show_more', 'Vis {count} mere', { count: '{count}' })"
:default-expand-all="autoExpandAll"
:aria-label="treeText('aria_label', 'Fakturaperiode objekttræ')"
@load-error="
(error, node) => {
nodeErrors[node.id] = SessionUser.functions.parseErrorMessage?.(error) || loadFailedMessage();
}
"
@node-click="onTreeNodeClick"
>
<template #icon="{ node, loading, error }">
<span class="icon is-small" :class="error ? 'has-text-danger' : getNodeIconColorClass(node)">
<i v-if="loading" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas" :class="getNodeIcon(node)"></i>
</span>
</template>
<template #default="{ node, loading, error, retry }">
<div
class="invoice-period-tree-node"
:data-testid="`invoice-period-tree-node-${node.id}`"
@mouseenter="showNodePreview(node)"
@focusin="showNodePreview(node)"
@mouseleave="hideNodePreview(node)"
@focusout="hideNodePreview(node)"
>
<div class="invoice-period-tree-node__row">
<div
class="invoice-period-tree-node__content"
:class="[
`invoice-period-tree-node__content--${node.type}`,
{ 'invoice-period-tree-node__content--with-fields': visibleNodeFields(node).length > 0 },
]"
>
<div class="invoice-period-tree-node__identity">
<div class="invoice-period-tree-node__main">
<span class="invoice-period-tree-node__label" :title="node.label">
<span class="invoice-period-tree-node__label-text">
<template v-if="shouldRenderCollectionRangeLabel(node)">
<span>{{ node.meta.relativeLabel.startLabel }}</span>
<span class="icon is-small invoice-period-tree-node__range-icon" aria-hidden="true">
<i class="fas fa-arrow-right"></i>
</span>
<span>{{ node.meta.relativeLabel.endLabel }}</span>
</template>
<template v-else>
{{ node.label }}
</template>
</span>
<InvoicingPeriodFlagBadge
:key="`${node.id}:title-flags`"
:flags="getNodeFlags(node)"
@status-changed="handleFlagStatusChanged"
/>
</span>
<span
v-if="node.meta?.count !== null && node.meta?.count !== undefined"
class="tag is-light is-small"
>
{{ node.meta.count }}
</span>
</div>
<div
v-if="getNodeSubtitle(node)"
class="invoice-period-tree-node__subtitle"
:title="getNodeSubtitle(node)"
>
{{ getNodeSubtitle(node) }}
</div>
</div>
<div
v-if="visibleNodeFields(node).length > 0"
class="invoice-period-tree-node__fields"
:class="`invoice-period-tree-node__fields--${node.type}`"
@click.stop
>
<div
v-for="field in visibleNodeFields(node)"
:key="field.key"
class="invoice-period-tree-node-field"
:class="{
'is-empty': valueIsEmpty(field.value),
'is-editable': field.editable,
'is-numeric': field.numeric,
'is-composite': field.lines?.length,
}"
:style="{
gridColumn: `${field.slot || 1} / span ${field.span || 1}`,
gridRow: field.row ? String(field.row) : undefined,
}"
:data-testid="`invoice-period-tree-field-${node.id}-${field.key}`"
>
<span class="invoice-period-tree-node-field__label">
<span>{{ field.label }}</span>
<InvoicingPeriodFlagBadge
:key="`${node.id}:${field.key}:flags`"
:flags="getFieldFlags(node, field.key)"
@status-changed="handleFlagStatusChanged"
/>
</span>
<span
v-if="!field.lines?.length"
class="invoice-period-tree-node-field__value"
:title="field.display"
:data-testid="`invoice-period-tree-field-value-${node.id}-${field.key}`"
>
<EditableTableColumn
v-if="field.editable"
component-wrapper="span"
theme="simple"
:object="field.object"
:column="field.column"
:load-list="refreshAfterInlineEdit"
:parse-function="fieldParseFunction(field)"
:edit-function="fieldEditFunction(field)"
:permission-check-function="() => field.editable === true"
:cell-test-id="`invoice-period-tree-field-editor-${node.id}-${field.key}`"
/>
<span v-else>
{{ field.display }}
</span>
</span>
<span v-else class="invoice-period-tree-node-field__lines">
<span
v-for="line in field.lines"
:key="line.key"
class="invoice-period-tree-node-field__line"
:data-testid="`invoice-period-tree-field-${node.id}-${line.key}`"
>
<span class="invoice-period-tree-node-field__line-label">{{ line.label }}</span>
<EditableTableColumn
v-if="line.editable"
component-wrapper="span"
theme="simple"
:object="line.object"
:column="line.column"
:load-list="refreshAfterInlineEdit"
:parse-function="fieldParseFunction(line)"
:edit-function="fieldEditFunction(line)"
:permission-check-function="() => line.editable === true"
:cell-test-id="`invoice-period-tree-field-editor-${node.id}-${line.key}`"
/>
<span v-else>{{ line.display }}</span>
</span>
</span>
</div>
</div>
<div v-if="error || nodeErrors[node.id]" class="invoice-period-tree-node__error">
<span>{{ nodeErrors[node.id] || loadFailedMessage() }}</span>
<button
type="button"
class="button is-small is-danger is-light"
:class="{ 'is-loading': loading }"
:data-testid="`invoice-period-tree-retry-${node.id}`"
@click.stop="retry"
>
{{ treeText("buttons.retry", "Prøv igen") }}
</button>
</div>
<div
v-if="isPreviewVisible(node)"
class="invoice-period-tree-node-preview"
:data-testid="`invoice-period-tree-preview-${node.id}`"
@click.stop
>
<div v-if="isPreviewLoading(node)" class="invoice-period-tree-node-preview__loading">
<span class="icon is-small"><i class="fas fa-spinner fa-spin"></i></span>
<span>{{ treeText("preview.loading", "Indlæser preview") }}</span>
</div>
<div v-else-if="previewError(node)" class="invoice-period-tree-node-preview__error">
{{ previewError(node) }}
</div>
<template v-else-if="node.type === TREE_NODE_TYPES.ATTACHMENT">
<template
v-if="attachmentPreviewSource(node)?.kind === 'image' && attachmentPreviewSource(node)?.url"
>
<img
class="invoice-period-tree-node-preview__image"
:src="attachmentPreviewSource(node).url"
:alt="node.label"
/>
</template>
<iframe
v-else-if="attachmentPreviewSource(node)?.kind === 'pdf' && attachmentPreviewSource(node)?.url"
class="invoice-period-tree-node-preview__document"
:src="attachmentPreviewSource(node).url"
:title="node.label"
></iframe>
<iframe
v-else-if="attachmentPreviewSource(node)?.kind === 'office' && attachmentPreviewSource(node)?.url"
class="invoice-period-tree-node-preview__document"
:src="attachmentPreviewSource(node).url"
:title="node.label"
></iframe>
<div v-else class="invoice-period-tree-node-preview__fallback">
<span class="icon is-small"><i class="fas fa-download"></i></span>
<span>{{ treeText("preview.download_on_click", "Klik for at downloade") }}</span>
</div>
</template>
<template v-else-if="node.type === TREE_NODE_TYPES.ECONOMIC_INVOICE">
<div class="invoice-period-tree-node-preview__header">
<span class="icon is-small"><i class="fas fa-file-invoice"></i></span>
<span>{{ treeText("preview.economic_invoice", "E-conomic faktura") }}</span>
</div>
<dl class="invoice-period-tree-node-preview__rows">
<template v-for="row in economicPreviewRows(node)" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<ul
v-if="economicPreviewWarnings(node).length > 0"
class="invoice-period-tree-node-preview__warnings"
>
<li v-for="warning in economicPreviewWarnings(node)" :key="warning">{{ warning }}</li>
</ul>
<div class="invoice-period-tree-node-preview__fallback">
<span class="icon is-small"><i class="fas fa-download"></i></span>
<span>{{ treeText("preview.download_on_click", "Klik for at downloade") }}</span>
</div>
</template>
</div>
</div>
<div class="invoice-period-tree-node__actions" @click.stop @mousedown.stop>
<ActionSettingsWheelButton
v-if="nodeActionSections(node).length > 0"
trigger-button-variant="text"
v-bind="nodeActionWheelProps(node)"
:menu-sections="nodeActionSections(node)"
:data-testid="`invoice-period-tree-action-wheel-${node.id}`"
/>
</div>
</div>
</div>
</template>
</BuefyTree>
</div>
</template>
<style scoped>
.invoice-period-object-tree {
border-top: 1px solid #e4e7ec;
margin-top: 0.75rem;
padding-top: 0.75rem;
}
.invoice-period-tree-toolbar {
align-items: center;
background: #f8fafc;
border: 1px solid #d9e0e8;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
justify-content: space-between;
margin-bottom: 0.75rem;
padding: 0.5rem;
}
.invoice-period-tree-toolbar__actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.invoice-period-tree-toolbar__controls {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.invoice-period-tree-toggle-all {
min-width: 8.5rem;
}
.invoice-period-tree-actions-dropdown {
flex: 0 0 auto;
}
.invoice-period-tree-actions-dropdown .dropdown-trigger .button {
gap: 0.35rem;
text-transform: capitalize;
}
.invoice-period-tree-actions-trigger-count {
min-width: 1.45rem;
}
.invoice-period-tree-actions-dropdown .dropdown-menu {
min-width: min(18rem, calc(100vw - 2rem));
z-index: 30;
}
.invoice-period-tree-actions-menu {
max-height: min(70vh, 32rem);
overflow: auto;
padding: 0.35rem 0;
}
.invoice-period-tree-action {
align-items: center;
background: transparent;
border: 0;
cursor: pointer;
display: flex;
gap: 0.75rem;
justify-content: space-between;
text-align: left;
width: 100%;
}
.invoice-period-tree-action__main {
align-items: center;
display: flex;
gap: 0.4rem;
min-width: 0;
}
.invoice-period-tree-action__main > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-period-tree-action__count {
flex: 0 0 auto;
min-width: 1.45rem;
}
.invoice-period-tree-action.is-disabled {
cursor: not-allowed;
opacity: 0.55;
}
.invoice-period-tree-action:not(.is-disabled):hover {
background: #f3f6f9;
}
.invoice-period-tree-node {
container-type: inline-size;
min-width: 0;
width: 100%;
}
.invoice-period-tree-node__row {
align-items: flex-start;
display: grid;
gap: 0.65rem;
grid-template-columns: minmax(0, 1fr) 2.25rem;
min-width: 0;
width: 100%;
}
.invoice-period-tree-node__content {
min-width: 0;
}
.invoice-period-tree-node__content--with-fields {
align-items: start;
column-gap: 0.55rem;
display: grid;
grid-template-columns: minmax(9rem, 13rem) minmax(0, 1fr);
}
.invoice-period-tree-node__content--order {
grid-template-columns: minmax(9rem, 13rem) minmax(0, 1fr);
}
.invoice-period-tree-node__identity {
min-width: 0;
}
.invoice-period-tree-node__main {
align-items: center;
display: flex;
flex-wrap: nowrap;
gap: 0.35rem;
line-height: 1.25;
min-height: 1.45rem;
min-width: 0;
}
.invoice-period-tree-node__label {
align-items: center;
color: #242a31;
display: inline-flex;
flex: 1 1 auto;
gap: 0.25rem;
font-weight: 650;
min-width: 0;
}
.invoice-period-tree-node__content--order .invoice-period-tree-node__label {
font-size: 0.78rem;
font-weight: 600;
}
.invoice-period-tree-node__label-text {
align-items: center;
display: inline-flex;
gap: 0.25rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-period-tree-node__content--order_item .invoice-period-tree-node__label-text {
overflow: visible;
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.invoice-period-tree-node__main .tag {
flex: 0 0 auto;
max-width: 8rem;
}
.invoice-period-tree-node__range-icon {
color: #7a8699;
height: 1rem;
width: 1rem;
}
.invoice-period-tree-node__subtitle {
color: #697386;
font-size: 0.78rem;
line-height: 1.25;
margin-top: 0.1rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-period-tree-node__content--order .invoice-period-tree-node__subtitle {
font-size: 0.68rem;
}
.invoice-period-tree-node__fields {
align-items: stretch;
display: grid;
gap: 0.28rem;
grid-template-columns: repeat(8, minmax(0, 1fr));
margin-top: 0;
max-width: 100%;
min-width: 0;
}
.invoice-period-tree-node-field {
background: #f8fafc;
border: 1px solid #e2e7ef;
border-radius: 5px;
display: grid;
gap: 0.08rem;
grid-template-rows: auto minmax(1rem, auto);
min-height: 2.35rem;
min-width: 0;
padding: 0.16rem 0.32rem;
}
.invoice-period-tree-node-field.is-composite {
gap: 0.18rem;
grid-template-rows: auto auto;
min-height: 0;
padding-bottom: 0.28rem;
padding-top: 0.22rem;
}
.invoice-period-tree-node-field.is-empty {
background: #fbfcfe;
border-color: #edf1f6;
}
.invoice-period-tree-node-field__label {
align-items: center;
color: #687385;
display: flex;
flex-wrap: wrap;
font-size: 0.68rem;
font-weight: 700;
gap: 0.18rem;
justify-content: space-between;
line-height: 1.1;
min-width: 0;
text-transform: uppercase;
}
@container (max-width: 38rem) {
.invoice-period-tree-node__content--with-fields,
.invoice-period-tree-node__content--order {
grid-template-columns: minmax(0, 1fr);
row-gap: 0.28rem;
}
.invoice-period-tree-node__fields,
.invoice-period-tree-node__fields--order,
.invoice-period-tree-node__fields--order_item,
.invoice-period-tree-node__fields--collected_order_invoice {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.invoice-period-tree-node-field {
grid-column: auto !important;
grid-row: auto !important;
}
.invoice-period-tree-node-field.is-composite {
grid-column: 1 / -1 !important;
}
}
@container (max-width: 24rem) {
.invoice-period-tree-node__fields,
.invoice-period-tree-node__fields--order,
.invoice-period-tree-node__fields--order_item,
.invoice-period-tree-node__fields--collected_order_invoice {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.invoice-period-tree-node-field__label > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-period-tree-node-field__value {
color: #27313d;
display: block;
font-size: 0.76rem;
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-period-tree-node-field.is-empty .invoice-period-tree-node-field__value {
color: #8a94a3;
font-style: italic;
}
.invoice-period-tree-node-field.is-numeric .invoice-period-tree-node-field__label,
.invoice-period-tree-node-field.is-numeric .invoice-period-tree-node-field__value,
.invoice-period-tree-node-field.is-numeric :deep(.hover-illustration) {
text-align: right;
}
.invoice-period-tree-node-field.is-numeric .invoice-period-tree-node-field__value,
.invoice-period-tree-node-field.is-numeric :deep(.hover-illustration) {
font-variant-numeric: tabular-nums;
}
.invoice-period-tree-node-field__lines {
display: grid;
gap: 0.18rem;
min-width: 0;
}
.invoice-period-tree-node-field__line {
align-items: start;
display: grid;
font-size: 0.74rem;
gap: 0.3rem;
grid-template-columns: minmax(3rem, max-content) minmax(0, 1fr);
line-height: 1.25;
min-width: 0;
}
.invoice-period-tree-node-field__line-label {
color: #7a8492;
font-size: 0.64rem;
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
}
.invoice-period-tree-node-field__line > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
}
.invoice-period-tree-node-field__line :deep(.hover-illustration) {
overflow-wrap: anywhere;
white-space: normal;
}
.invoice-period-tree-node-field :deep(.hover-illustration) {
color: #27313d;
display: block;
font-size: 0.76rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-period-tree-node-field :deep(.hover-illustration > div) {
min-width: 0;
}
.invoice-period-tree-node__actions {
align-items: flex-start;
display: flex;
justify-content: center;
min-height: 1.75rem;
padding-top: 0.02rem;
width: 2.25rem;
}
.invoice-period-tree-node__actions :deep(.action-settings-wheel-dropdown) {
display: inline-flex;
}
.invoice-period-tree-node__actions :deep(.action-settings-wheel-trigger) {
height: 1.75rem;
width: 1.75rem;
}
.invoice-period-tree-node__error {
align-items: center;
color: #cc0f35;
display: flex;
flex-wrap: wrap;
font-size: 0.78rem;
gap: 0.35rem;
grid-column: 1 / -1;
margin-top: 0.25rem;
}
.invoice-period-tree-node-preview {
background: #ffffff;
border: 1px solid #d9e1ec;
border-radius: 6px;
box-shadow: 0 10px 24px rgba(22, 34, 51, 0.12);
color: #27313d;
margin-top: 0.45rem;
max-width: min(34rem, calc(100vw - 5rem));
overflow: hidden;
padding: 0.5rem;
position: relative;
z-index: 20;
grid-column: 1 / -1;
}
.invoice-period-tree-node-preview__loading,
.invoice-period-tree-node-preview__error,
.invoice-period-tree-node-preview__fallback,
.invoice-period-tree-node-preview__header {
align-items: center;
display: flex;
gap: 0.35rem;
font-size: 0.78rem;
}
.invoice-period-tree-node-preview__error {
color: #cc0f35;
}
.invoice-period-tree-node-preview__fallback {
color: #576273;
margin-top: 0.35rem;
}
.invoice-period-tree-node-preview__image {
display: block;
max-height: 18rem;
max-width: 100%;
object-fit: contain;
}
.invoice-period-tree-node-preview__document {
border: 0;
display: block;
height: 18rem;
width: min(30rem, calc(100vw - 6rem));
}
.invoice-period-tree-node-preview__rows {
display: grid;
font-size: 0.78rem;
gap: 0.18rem 0.75rem;
grid-template-columns: max-content minmax(0, 1fr);
margin: 0.35rem 0 0;
}
.invoice-period-tree-node-preview__rows dt {
color: #697386;
font-weight: 700;
}
.invoice-period-tree-node-preview__rows dd {
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.invoice-period-tree-node-preview__warnings {
color: #8a5b00;
font-size: 0.74rem;
margin: 0.4rem 0 0 1rem;
}
@media screen and (max-width: 768px) {
.invoice-period-tree-toolbar {
align-items: stretch;
display: block;
}
.invoice-period-tree-toolbar__controls,
.invoice-period-tree-toggle-all {
width: 100%;
}
.invoice-period-tree-toolbar__controls .tag {
width: fit-content;
}
.invoice-period-tree-toolbar__actions {
margin-top: 0.5rem;
}
.invoice-period-tree-toolbar__actions .button,
.invoice-period-tree-actions-dropdown {
flex: 1 1 11rem;
}
.invoice-period-tree-actions-dropdown .dropdown-trigger,
.invoice-period-tree-actions-dropdown .button {
width: 100%;
}
.invoice-period-tree-node__row {
gap: 0.35rem;
grid-template-columns: minmax(0, 1fr) 2rem;
}
.invoice-period-tree-node__content--with-fields,
.invoice-period-tree-node__content--order {
grid-template-columns: minmax(0, 1fr);
row-gap: 0.28rem;
}
.invoice-period-tree-node__fields,
.invoice-period-tree-node__fields--order,
.invoice-period-tree-node__fields--order_item,
.invoice-period-tree-node__fields--collected_order_invoice {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invoice-period-tree-node-field {
grid-column: auto !important;
grid-row: auto !important;
}
.invoice-period-tree-node__actions {
width: 2rem;
}
.invoice-period-tree-node-preview {
max-width: calc(100vw - 3rem);
}
.invoice-period-tree-node-preview__document {
width: calc(100vw - 4rem);
}
}
</style>