Add a persistent recursive expand/collapse control above collected invoices and keep order-item quantity and price in the canonical editable field layout.
3858 lines
128 KiB
Vue
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
|
|
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>
|