Align invoicing period review workspace (#251)

Keep review navigation, customer cards, metadata, date labels, and direct order-item tables aligned across desktop and responsive layouts.
This commit is contained in:
Jeppe B
2026-08-03 13:45:11 +02:00
committed by GitHub
parent 7a5ee1aa5b
commit f995440098
14 changed files with 1014 additions and 257 deletions
+2 -1
View File
@@ -96,6 +96,7 @@ onBeforeUnmount(disconnectLoadMoreObserver);
> >
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)"> <div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
<button <button
v-if="node.hideToggle !== true"
type="button" type="button"
class="button is-white is-small b-tree-node-toggle" class="button is-white is-small b-tree-node-toggle"
:class="{ 'is-invisible': !hasExpandToggle }" :class="{ 'is-invisible': !hasExpandToggle }"
@@ -122,7 +123,7 @@ onBeforeUnmount(disconnectLoadMoreObserver);
/> />
</span> </span>
<span class="b-tree-node-icon"> <span v-if="node.hideIcon !== true" class="b-tree-node-icon">
<slot name="icon" :node="node" :expanded="isExpanded" :loading="isLoading" :error="hasLoadError"> <slot name="icon" :node="node" :expanded="isExpanded" :loading="isLoading" :error="hasLoadError">
<span class="icon is-small"> <span class="icon is-small">
<i class="fas" :class="hasExpandToggle ? (isExpanded ? 'fa-folder-open' : 'fa-folder') : 'fa-file'"></i> <i class="fas" :class="hasExpandToggle ? (isExpanded ? 'fa-folder-open' : 'fa-folder') : 'fa-file'"></i>
@@ -437,7 +437,7 @@ const saveFlagStatus = async (flag: any, status: string) => {
<style scoped> <style scoped>
.invoice-period-flags { .invoice-period-flags {
margin: -0.35rem 3.25rem 0.65rem 1.65rem; margin: -0.15rem 0.6rem 0.65rem;
} }
.invoice-period-flags--compact { .invoice-period-flags--compact {
@@ -445,31 +445,44 @@ const saveFlagStatus = async (flag: any, status: string) => {
} }
.invoice-period-flag-row { .invoice-period-flag-row {
display: grid; align-items: start;
grid-template-columns: 1.2rem minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: center;
min-height: 1.75rem;
padding: 0.15rem 0.35rem;
margin-top: 0.35rem;
background: #fff; background: #fff;
column-gap: 0.45rem;
display: grid;
grid-template-columns: 1.2rem minmax(0, 1fr);
min-height: 1.75rem;
margin-top: 0.35rem;
padding: 0.15rem 0.35rem;
position: relative;
} }
.invoice-period-flag-row__message { .invoice-period-flag-row__message {
min-width: 0;
color: #4a4a4a; color: #4a4a4a;
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.25; line-height: 1.25;
min-width: 0;
overflow-wrap: anywhere;
text-align: left;
} }
.invoice-period-flag-row__actions { .invoice-period-flag-row__actions {
grid-column: 2;
justify-self: end;
margin-bottom: 0; margin-bottom: 0;
max-height: 0;
opacity: 0; opacity: 0;
transition: opacity 120ms ease; overflow: hidden;
pointer-events: none;
transition: max-height 120ms ease, opacity 120ms ease;
} }
.invoice-period-flag-row:hover .invoice-period-flag-row__actions { .invoice-period-flag-row:hover .invoice-period-flag-row__actions,
.invoice-period-flag-row:focus-within .invoice-period-flag-row__actions {
margin-top: 0.2rem;
max-height: 2.5rem;
opacity: 1; opacity: 1;
overflow: visible;
pointer-events: auto;
} }
.invoice-period-flag-row__tooltip { .invoice-period-flag-row__tooltip {
@@ -8,6 +8,7 @@ import EditableTableColumn from "@/components/displays/buttons/EditableTableColu
import InvoicingPeriodFlagBadge from "./InvoicingPeriodFlagBadge.vue"; import InvoicingPeriodFlagBadge from "./InvoicingPeriodFlagBadge.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getAttachmentPreviewKind, releaseObjectUrl } from "@/services/attachmentPreview.js"; import { getAttachmentPreviewKind, releaseObjectUrl } from "@/services/attachmentPreview.js";
import { getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import { invoiceQueue } from "../imports/InvoicingBillingPeriodImportInvoiceQueue.vue"; import { invoiceQueue } from "../imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js"; import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
import { import {
@@ -25,7 +26,7 @@ import {
TREE_CATEGORY_TYPES, TREE_CATEGORY_TYPES,
TREE_NODE_TYPES, TREE_NODE_TYPES,
buildCollectionRootNodes, buildCollectionRootNodes,
buildOrderItemTree, buildOrderItemTableRows,
classifyAttachment, classifyAttachment,
hasWashCertificateOrderItem, hasWashCertificateOrderItem,
makeAttachmentNode, makeAttachmentNode,
@@ -127,6 +128,18 @@ const treeText = (key: string, fallback: string, params: Record<string, any> = {
tr(`invoicing_period.object_tree.${key}`, fallback, params); tr(`invoicing_period.object_tree.${key}`, fallback, params);
const commonText = (key: string, fallback: string, params: Record<string, any> = {}) => const commonText = (key: string, fallback: string, params: Record<string, any> = {}) =>
tr(`common.${key}`, fallback, params); tr(`common.${key}`, fallback, params);
const orderDepartmentLabel = (order: any) => {
const explicitName = order?.department_name || order?.department?.name;
if (explicitName) {
return explicitName;
}
const departmentId = toPositiveInteger(order?.department_id);
if (!departmentId) {
return null;
}
const resolvedName = getDepartmentName(departmentId);
return resolvedName && String(resolvedName) !== String(departmentId) ? resolvedName : `#${departmentId}`;
};
const loadFailedMessage = () => treeText("errors.load_failed", "Kunne ikke indlæse indholdet."); const loadFailedMessage = () => treeText("errors.load_failed", "Kunne ikke indlæse indholdet.");
const openUrlInNewTab = (url: string | null | undefined) => { const openUrlInNewTab = (url: string | null | undefined) => {
if (!url || typeof window === "undefined") { if (!url || typeof window === "undefined") {
@@ -164,6 +177,11 @@ const legacyDataSignature = computed(() =>
transactions: props.transactions.map((transaction) => ({ transactions: props.transactions.map((transaction) => ({
id: transaction?.id, id: transaction?.id,
collectionId: transaction?.invoice_collection_id, collectionId: transaction?.invoice_collection_id,
date:
transaction?.date ??
transaction?.created_at ??
transaction?.closed_at ??
transaction?.updated_at,
amount: transaction?.total_net_amount ?? transaction?.amount, amount: transaction?.total_net_amount ?? transaction?.amount,
state: transaction?.invoice_state, state: transaction?.invoice_state,
})), })),
@@ -195,8 +213,35 @@ const rememberNodes = (nodes: TreeNode[]) => {
}; };
const rootNodes = ref<TreeNode[]>([]); const rootNodes = ref<TreeNode[]>([]);
const orderItemNodeKeys = (orderId: any) => {
const normalizedOrderId = toPositiveInteger(orderId);
return Object.values(nodeById.value)
.filter(
(node) =>
node.type === TREE_NODE_TYPES.ORDER_ITEM && toPositiveInteger(node.meta?.orderId) === normalizedOrderId
)
.map((node) => node.id);
};
const areAllOrderItemsChecked = (orderId: any) => {
const keys = orderItemNodeKeys(orderId);
return keys.length > 0 && keys.every((key) => checkedKeys.value.includes(key));
};
const areSomeOrderItemsChecked = (orderId: any) => {
const keys = orderItemNodeKeys(orderId);
const selectedCount = keys.filter((key) => checkedKeys.value.includes(key)).length;
return selectedCount > 0 && selectedCount < keys.length;
};
const toggleOrderItemRows = (orderId: any) => {
const keys = orderItemNodeKeys(orderId);
const keySet = new Set(keys);
checkedKeys.value = areAllOrderItemsChecked(orderId)
? checkedKeys.value.filter((key) => !keySet.has(key))
: [...new Set([...checkedKeys.value, ...keys])];
};
const snapshotTreeLabels = () => ({ const snapshotTreeLabels = () => ({
collection: nodeLabel.collection, collection: nodeLabel.collection,
locale: localeValue(),
ordersWithoutCollection: nodeLabel.ordersWithoutCollection(), ordersWithoutCollection: nodeLabel.ordersWithoutCollection(),
orders: treeText("categories.orders", "Orders"), orders: treeText("categories.orders", "Orders"),
items: treeText("categories.order_items", "Ordrelinjer"), items: treeText("categories.order_items", "Ordrelinjer"),
@@ -222,8 +267,6 @@ const buildLegacyRootNodes = () =>
buildCollectionRootNodes(props.customer, props.transactions, props.excludedOrderIds, { buildCollectionRootNodes(props.customer, props.transactions, props.excludedOrderIds, {
collection: nodeLabel.collection, collection: nodeLabel.collection,
ordersWithoutCollection: nodeLabel.ordersWithoutCollection(), ordersWithoutCollection: nodeLabel.ordersWithoutCollection(),
dateFrom: props.dates?.dateFrom,
dateTo: props.dates?.dateTo,
locale: localeValue(), locale: localeValue(),
}); });
@@ -490,10 +533,6 @@ const loadTreeNodeChildren = async (node: TreeNode) => {
if (requestedSignature !== currentSignature.value) { if (requestedSignature !== currentSignature.value) {
return []; return [];
} }
await preloadDefaultExpandedChildren(node, children);
if (requestedSignature !== currentSignature.value) {
return [];
}
return rememberNodes(children); return rememberNodes(children);
} catch (error: any) { } catch (error: any) {
if (requestedSignature !== currentSignature.value) { if (requestedSignature !== currentSignature.value) {
@@ -505,30 +544,6 @@ const loadTreeNodeChildren = async (node: TreeNode) => {
} }
}; };
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[]> => { const loadChildrenForNode = async (node: TreeNode): Promise<TreeNode[]> => {
if (node.type === TREE_NODE_TYPES.COLLECTION) { if (node.type === TREE_NODE_TYPES.COLLECTION) {
return buildCollectionCategoryNodes(node); return buildCollectionCategoryNodes(node);
@@ -709,10 +724,6 @@ const loadCategoryChildren = async (node: TreeNode): Promise<TreeNode[]> => {
return buildEconomicInvoiceNodes(node); return buildEconomicInvoiceNodes(node);
} }
if (node.category === TREE_CATEGORY_TYPES.ORDER_ITEMS) {
return loadOrderItems(node.meta.orderId);
}
if ( if (
node.category === TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_CERTIFICATES || node.category === TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_CERTIFICATES ||
node.category === TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_IMAGES || node.category === TREE_CATEGORY_TYPES.ORDER_ATTACHMENTS_IMAGES ||
@@ -945,18 +956,12 @@ const loadOrderCategoryNodes = async (orderNode: TreeNode) => {
getCachedAttachmentRows(orderId, attachmentSeed), getCachedAttachmentRows(orderId, attachmentSeed),
]); ]);
const attachmentGroups = groupAttachments(attachments); const attachmentGroups = groupAttachments(attachments);
const categories: TreeNode[] = [ const itemNodes = buildOrderItemTableRows(orderItems, {
makeCategoryNode({ fallbackLabel: (item: any) => nodeLabel.orderItemFallback(item?.id ?? item?.order_item_id),
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, orderId, TREE_CATEGORY_TYPES.ORDER_ITEMS), invoiceState: getNodeInvoiceState(orderNode),
label: treeText("categories.order_items", "Order items"), orderId,
category: TREE_CATEGORY_TYPES.ORDER_ITEMS, });
parentType: orderNode.type, const categories: TreeNode[] = [];
parentId: orderNode.id,
icon: "fa-list-check",
checkable: true,
meta: { orderId, items: orderItems, invoiceState: getNodeInvoiceState(orderNode) },
}),
];
if (shouldShowWashCertificateCategory(orderId, orderItems, attachmentGroups)) { if (shouldShowWashCertificateCategory(orderId, orderItems, attachmentGroups)) {
categories.push( categories.push(
@@ -1016,7 +1021,7 @@ const loadOrderCategoryNodes = async (orderNode: TreeNode) => {
}) })
); );
} }
return categories; return [...itemNodes, ...categories];
}; };
const loadOrder = async (orderId: number | null, fallback: any = null) => { const loadOrder = async (orderId: number | null, fallback: any = null) => {
@@ -1029,15 +1034,6 @@ const loadOrder = async (orderId: number | null, fallback: any = null) => {
const nodeOrderId = (node: TreeNode) => toPositiveInteger(node?.meta?.orderId ?? node?.meta?.order?.id); 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[]) => const makeAttachmentCategory = (orderId: number, category: string, label: string, attachments: any[]) =>
makeCategoryNode({ makeCategoryNode({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, orderId, category), id: makeNodeId(TREE_NODE_TYPES.CATEGORY, orderId, category),
@@ -1785,6 +1781,13 @@ const getNodeFields = (node: TreeNode): NodeField[] => {
const object = collectionObjectForNode(node); const object = collectionObjectForNode(node);
const columns = SessionUser.objects.collectedOrderInvoices.columns; const columns = SessionUser.objects.collectedOrderInvoices.columns;
return [ return [
makeNodeField({
key: "state",
label: treeText("fields.collection_state", "Status"),
value: object.state,
editable: false,
slot: 1,
}),
makeNodeField({ makeNodeField({
key: "po_number", key: "po_number",
label: fieldLabel(columns, "po_number", "PO"), label: fieldLabel(columns, "po_number", "PO"),
@@ -1805,6 +1808,13 @@ const getNodeFields = (node: TreeNode): NodeField[] => {
editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm, editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm,
slot: 3, slot: 3,
}), }),
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({ makeNodeField({
key: "external_id", key: "external_id",
label: fieldLabel(columns, "external_id", "Eksternt ID"), label: fieldLabel(columns, "external_id", "Eksternt ID"),
@@ -1825,18 +1835,11 @@ const getNodeFields = (node: TreeNode): NodeField[] => {
slot: 6, slot: 6,
}), }),
makeNodeField({ makeNodeField({
key: "state", key: "processor",
label: treeText("fields.collection_state", "Status"), label: treeText("fields.processor", "Behandler"),
value: object.state, value: object.processor,
editable: false, editable: false,
slot: 1, slot: 7,
}),
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({ makeNodeField({
key: "total_net_amount", key: "total_net_amount",
@@ -1846,13 +1849,6 @@ const getNodeFields = (node: TreeNode): NodeField[] => {
slot: 8, slot: 8,
numeric: true, numeric: true,
}), }),
makeNodeField({
key: "processor",
label: treeText("fields.processor", "Behandler"),
value: object.processor,
editable: false,
slot: 7,
}),
makeNodeField({ makeNodeField({
key: "error_message", key: "error_message",
label: treeText("fields.error_message", "Fejl"), label: treeText("fields.error_message", "Fejl"),
@@ -1916,6 +1912,13 @@ const getNodeFields = (node: TreeNode): NodeField[] => {
editFunction: SessionUser.objects.orders.showEditObjectFieldForm, editFunction: SessionUser.objects.orders.showEditObjectFieldForm,
slot: 3, slot: 3,
}), }),
makeNodeField({
key: "department",
label: commonText("department", "Afdeling"),
value: orderDepartmentLabel(object),
editable: false,
slot: 7,
}),
registrationField(object, columns), registrationField(object, columns),
makeNodeField({ makeNodeField({
key: "include_in_invoice", key: "include_in_invoice",
@@ -3351,6 +3354,7 @@ const nodeActionWheelProps = (node: TreeNode) => {
</div> </div>
</div> </div>
<div class="invoice-period-object-tree__scroll">
<BuefyTree <BuefyTree
:key="currentSignature" :key="currentSignature"
ref="treeRef" ref="treeRef"
@@ -3388,7 +3392,101 @@ const nodeActionWheelProps = (node: TreeNode) => {
@mouseleave="hideNodePreview(node)" @mouseleave="hideNodePreview(node)"
@focusout="hideNodePreview(node)" @focusout="hideNodePreview(node)"
> >
<div class="invoice-period-tree-node__row"> <div
v-if="node.type === TREE_NODE_TYPES.ORDER_ITEM"
class="invoice-period-order-item-table"
:data-testid="`invoice-period-order-item-table-row-${node.meta.itemId}`"
>
<div
v-if="node.meta.showTableHeader"
class="invoice-period-order-item-table__header"
:data-testid="`invoice-period-order-item-table-header-${node.meta.orderId}`"
>
<label class="invoice-period-order-item-table__select-all" @click.stop>
<input
type="checkbox"
:checked="areAllOrderItemsChecked(node.meta.orderId)"
:indeterminate="areSomeOrderItemsChecked(node.meta.orderId)"
:aria-label="treeText('order_items.select_all', 'Vælg alle ordrelinjer')"
:data-testid="`invoice-period-order-item-table-select-all-${node.meta.orderId}`"
@change.stop="toggleOrderItemRows(node.meta.orderId)"
/>
<span aria-hidden="true">{{ commonText("product", "Produkt") }}</span>
</label>
<span v-for="field in visibleNodeFields(node)" :key="field.key" aria-hidden="true">
{{ field.label }}
</span>
<span class="invoice-period-order-item-table__actions-header" aria-hidden="true">
{{ commonText("actions", "Handlinger") }}
</span>
</div>
<div class="invoice-period-order-item-table__row">
<div
class="invoice-period-order-item-table__product"
:style="{ '--order-item-depth': node.meta.tableDepth || 0 }"
>
<span v-if="node.meta.isAssociatedItem" class="invoice-period-order-item-table__relation" aria-hidden="true">
+
</span>
<span v-if="node.meta.isAssociatedItem" class="is-sr-only">
{{ treeText("order_items.associated_with", "Tilknyttet") }} {{ node.meta.associatedParentLabel }}
</span>
<span class="invoice-period-tree-node__label-text" :title="node.label">{{ node.label }}</span>
<InvoicingPeriodFlagBadge
:key="`${node.id}:title-flags`"
:flags="getNodeFlags(node)"
@status-changed="handleFlagStatusChanged"
/>
</div>
<div
v-for="field in visibleNodeFields(node)"
:key="field.key"
class="invoice-period-order-item-table__cell"
:class="{
'is-empty': valueIsEmpty(field.value),
'is-editable': field.editable,
'is-numeric': field.numeric,
}"
:data-testid="`invoice-period-tree-field-${node.id}-${field.key}`"
>
<span class="is-sr-only">{{ field.label }}:</span>
<span
class="invoice-period-order-item-table__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>
<InvoicingPeriodFlagBadge
:key="`${node.id}:${field.key}:flags`"
:flags="getFieldFlags(node, field.key)"
@status-changed="handleFlagStatusChanged"
/>
</div>
<div class="invoice-period-order-item-table__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>
<div v-else class="invoice-period-tree-node__row">
<div <div
class="invoice-period-tree-node__content" class="invoice-period-tree-node__content"
:class="[ :class="[
@@ -3442,12 +3540,15 @@ const nodeActionWheelProps = (node: TreeNode) => {
v-for="field in visibleNodeFields(node)" v-for="field in visibleNodeFields(node)"
:key="field.key" :key="field.key"
class="invoice-period-tree-node-field" class="invoice-period-tree-node-field"
:class="{ :class="[
`invoice-period-tree-node-field--${field.key}`,
{
'is-empty': valueIsEmpty(field.value), 'is-empty': valueIsEmpty(field.value),
'is-editable': field.editable, 'is-editable': field.editable,
'is-numeric': field.numeric, 'is-numeric': field.numeric,
'is-composite': field.lines?.length, 'is-composite': field.lines?.length,
}" },
]"
:style="{ :style="{
gridColumn: `${field.slot || 1} / span ${field.span || 1}`, gridColumn: `${field.slot || 1} / span ${field.span || 1}`,
gridRow: field.row ? String(field.row) : undefined, gridRow: field.row ? String(field.row) : undefined,
@@ -3599,6 +3700,7 @@ const nodeActionWheelProps = (node: TreeNode) => {
</template> </template>
</BuefyTree> </BuefyTree>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -3608,6 +3710,14 @@ const nodeActionWheelProps = (node: TreeNode) => {
padding-top: 0.75rem; padding-top: 0.75rem;
} }
.invoice-period-object-tree__scroll {
overflow-x: auto;
}
.invoice-period-object-tree__scroll :deep(.b-tree) {
min-width: 40rem;
}
.invoice-period-tree-toolbar { .invoice-period-tree-toolbar {
align-items: center; align-items: center;
background: #f8fafc; background: #f8fafc;
@@ -3707,6 +3817,145 @@ const nodeActionWheelProps = (node: TreeNode) => {
width: 100%; width: 100%;
} }
.invoice-period-order-item-table {
min-width: 40rem;
width: 100%;
}
.invoice-period-order-item-table__header,
.invoice-period-order-item-table__row {
align-items: center;
column-gap: 0.35rem;
display: grid;
grid-template-columns:
minmax(10rem, 2fr)
minmax(6rem, 1fr)
minmax(7rem, 1.2fr)
minmax(3.75rem, 0.5fr)
minmax(5rem, 0.7fr)
2.25rem;
min-width: 0;
}
.invoice-period-order-item-table__header {
background: #f3f6f9;
border-bottom: 1px solid #d9e1ec;
border-top: 1px solid #d9e1ec;
color: #687385;
font-size: 0.68rem;
font-weight: 700;
line-height: 1.15;
min-height: 1.8rem;
padding: 0.25rem 0;
text-transform: uppercase;
}
.invoice-period-order-item-table__header > span,
.invoice-period-order-item-table__select-all,
.invoice-period-order-item-table__cell,
.invoice-period-order-item-table__product {
min-width: 0;
padding: 0.25rem 0.32rem;
}
.invoice-period-order-item-table__select-all {
align-items: center;
display: flex;
gap: 0.4rem;
}
.invoice-period-order-item-table__select-all input {
margin: 0;
}
.invoice-period-order-item-table__header > span:nth-last-child(-n + 3),
.invoice-period-order-item-table__cell.is-numeric,
.invoice-period-order-item-table__actions-header {
text-align: right;
}
.invoice-period-order-item-table__row {
border-bottom: 1px solid #e7ebf0;
min-height: 2.25rem;
}
.invoice-period-order-item-table__product {
align-items: center;
display: flex;
gap: 0.25rem;
padding-left: calc(0.32rem + (var(--order-item-depth) * 0.9rem));
}
.invoice-period-order-item-table__product .invoice-period-tree-node__label-text {
overflow-wrap: anywhere;
white-space: normal;
}
.invoice-period-order-item-table__relation {
color: #7a8699;
flex: 0 0 auto;
font-weight: 700;
}
.invoice-period-order-item-table__cell {
align-items: center;
display: flex;
gap: 0.2rem;
}
.invoice-period-order-item-table__cell.is-numeric {
justify-content: flex-end;
}
.invoice-period-order-item-table__cell.is-empty .invoice-period-order-item-table__value {
color: #8a94a3;
font-style: italic;
}
.invoice-period-order-item-table__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-order-item-table__cell.is-numeric .invoice-period-order-item-table__value,
.invoice-period-order-item-table__cell.is-numeric :deep(.hover-illustration) {
font-variant-numeric: tabular-nums;
text-align: right;
}
.invoice-period-order-item-table__cell :deep(.hover-illustration) {
color: #27313d;
display: block;
font-size: 0.76rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-period-order-item-table__actions {
align-items: center;
display: flex;
justify-content: center;
min-height: 1.75rem;
width: 2.25rem;
}
.invoice-period-order-item-table__actions :deep(.action-settings-wheel-dropdown) {
display: inline-flex;
}
.invoice-period-order-item-table__actions :deep(.action-settings-wheel-trigger) {
height: 1.75rem;
width: 1.75rem;
}
.invoice-period-tree-node__row { .invoice-period-tree-node__row {
align-items: flex-start; align-items: flex-start;
display: grid; display: grid;
@@ -3852,7 +4101,7 @@ const nodeActionWheelProps = (node: TreeNode) => {
text-transform: uppercase; text-transform: uppercase;
} }
@container (max-width: 38rem) { @container (max-width: 56rem) {
.invoice-period-tree-node__content--with-fields, .invoice-period-tree-node__content--with-fields,
.invoice-period-tree-node__content--order { .invoice-period-tree-node__content--order {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -3872,7 +4121,16 @@ const nodeActionWheelProps = (node: TreeNode) => {
} }
.invoice-period-tree-node-field.is-composite { .invoice-period-tree-node-field.is-composite {
grid-column: 1 / -1 !important; grid-column: span 2 !important;
}
.invoice-period-tree-node-field--error_message {
grid-column: span 2 !important;
}
.invoice-period-tree-node-field--total_net_amount,
.invoice-period-tree-node-field--price {
grid-column: 4 !important;
} }
} }
@@ -3883,6 +4141,11 @@ const nodeActionWheelProps = (node: TreeNode) => {
.invoice-period-tree-node__fields--collected_order_invoice { .invoice-period-tree-node__fields--collected_order_invoice {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.invoice-period-tree-node-field--total_net_amount,
.invoice-period-tree-node-field--price {
grid-column: auto !important;
}
} }
.invoice-period-tree-node-field__label > span:first-child { .invoice-period-tree-node-field__label > span:first-child {
@@ -4123,6 +4386,16 @@ const nodeActionWheelProps = (node: TreeNode) => {
grid-row: auto !important; grid-row: auto !important;
} }
.invoice-period-tree-node-field.is-composite,
.invoice-period-tree-node-field--error_message {
grid-column: 1 / -1 !important;
}
.invoice-period-tree-node-field--total_net_amount,
.invoice-period-tree-node-field--price {
grid-column: auto !important;
}
.invoice-period-tree-node__actions { .invoice-period-tree-node__actions {
width: 2rem; width: 2rem;
} }
@@ -80,17 +80,17 @@ const getAttributeTestId = (viewKey: string) => {
display: grid; display: grid;
grid-template-rows: repeat(var(--customer-attribute-count), minmax(0, 1fr)); grid-template-rows: repeat(var(--customer-attribute-count), minmax(0, 1fr));
gap: 0; gap: 0;
width: max-content;
max-width: min(10rem, 18vw);
height: 2.45rem; height: 2.45rem;
max-width: 100%;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
width: 100%;
} }
.customer-attribute-text { .customer-attribute-text {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -105,7 +105,7 @@ const getAttributeTestId = (viewKey: string) => {
.customer-attribute-text :deep(.icon-text) { .customer-attribute-text :deep(.icon-text) {
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
line-height: 1; line-height: 1;
@@ -120,7 +120,7 @@ const getAttributeTestId = (viewKey: string) => {
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.customer-attribute-stack { .customer-attribute-stack {
max-width: 10rem; max-width: 100%;
} }
} }
</style> </style>
@@ -1178,9 +1178,8 @@ watch(
@click="(canActivateView(type) ? onClickType(type) : () => {})" @click="(canActivateView(type) ? onClickType(type) : () => {})"
@keydown.enter.prevent="(canActivateView(type) ? onClickType(type) : () => {})" @keydown.enter.prevent="(canActivateView(type) ? onClickType(type) : () => {})"
@keydown.space.prevent="(canActivateView(type) ? onClickType(type) : () => {})"> @keydown.space.prevent="(canActivateView(type) ? onClickType(type) : () => {})">
<div class="level is-mobile is-align-items-center"> <div class="period-selector-row">
<div class="level-left"> <div class="period-selector-label">
<div class="level-item">
<ColorIndicator <ColorIndicator
v-bind:label="{ v-bind:label="{
text: getSelectorLabelText(type), text: getSelectorLabelText(type),
@@ -1195,13 +1194,8 @@ watch(
v-bind:is_loading="isLoading" v-bind:is_loading="isLoading"
/> />
</div> </div>
</div> <div class="period-selector-progress-slot">
<div class="level-right"> <progress class="progress is-small" v-if="isLoading" v-bind:max="1" />
<div class="level-item period-selector-progress-slot">
<progress class="progress is-small"
v-if="isLoading"
v-bind:max="1"
/>
<template v-else-if="isViewAvailable(type) && shouldShowSelectorProgress(type)"> <template v-else-if="isViewAvailable(type) && shouldShowSelectorProgress(type)">
<div <div
class="period-selector-progress" class="period-selector-progress"
@@ -1215,18 +1209,12 @@ watch(
></span> ></span>
</div> </div>
</template> </template>
<progress class="progress" v-else-if="!isViewAvailable(type)" <progress class="progress" v-else-if="!isViewAvailable(type)" v-bind:value="0" v-bind:max="1" />
v-bind:value="0"
v-bind:max="1"
/>
</div> </div>
<div class="level-item"> <span class="icon is-small period-selector-chevron" aria-hidden="true">
<span class="icon is-small">
<i class="fas fa-chevron-right"></i> <i class="fas fa-chevron-right"></i>
</span> </span>
</div> </div>
</div>
</div>
</WhiteBox> </WhiteBox>
</div> </div>
</template> </template>
@@ -1268,53 +1256,71 @@ watch(
} }
.is-selector-view { .is-selector-view {
align-items: center;
display: flex;
min-height: 4rem;
overflow: hidden; overflow: hidden;
padding: 0.875rem 1rem;
width: 100%; width: 100%;
} }
.is-selector-view :deep(.level), .period-selector-row,
.is-selector-view :deep(.level-left), .period-selector-label,
.is-selector-view :deep(.level-right),
.is-selector-view :deep(.level-item),
.is-selector-view :deep(.color-indicator), .is-selector-view :deep(.color-indicator),
.is-selector-view :deep(.icon-text) { .is-selector-view :deep(.icon-text) {
min-width: 0; min-width: 0;
} }
.is-selector-view :deep(.level-left) { .period-selector-row {
flex: 1 1 auto;
overflow: hidden;
}
.is-selector-view :deep(.level) {
align-items: center; align-items: center;
display: grid; display: grid;
gap: 0.35rem; column-gap: 0.75rem;
grid-template-columns: minmax(0, 1fr) minmax(84px, 104px); grid-template-columns: minmax(0, 1fr) 72px 1rem;
width: 100%; width: 100%;
} }
.is-selector-view :deep(.level-left .icon-text > span:last-child) { .period-selector-label {
overflow: hidden;
}
.period-selector-label :deep(.color-indicator),
.period-selector-label :deep(.icon-text) {
width: 100%;
}
.period-selector-label :deep(.icon-text) {
align-items: center;
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr);
}
.period-selector-label :deep(.icon) {
margin: 0;
}
.period-selector-label :deep(.icon-text > span:last-child) {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.is-selector-view :deep(.level-right) { .period-selector-progress-slot {
align-items: center;
display: flex; display: flex;
flex: none; justify-content: stretch;
justify-content: flex-end; min-width: 0;
max-width: 104px;
min-width: 84px;
width: 100%; width: 100%;
} }
.is-selector-view :deep(.period-selector-progress-slot) { .period-selector-progress-slot > .progress {
flex: 1 1 72px; margin: 0;
max-width: 72px; width: 100%;
min-width: 56px; }
width: auto;
.period-selector-chevron {
justify-self: end;
margin: 0;
} }
.period-view-navigation__heading { .period-view-navigation__heading {
@@ -95,39 +95,14 @@ const formatMonthName = (date, locale = DEFAULT_LOCALE) => capitalizeFirst(new I
month: "long", month: "long",
}).format(date)); }).format(date));
const formatDayMonth = (date, locale = DEFAULT_LOCALE) => `${date.getDate()} ${formatMonthName(date, locale)}`; const formatDayMonth = (date, locale = DEFAULT_LOCALE) => `${date.getDate()} ${formatMonthName(date, locale)}`;
const lastDayOfMonth = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
const isSameMonth = (left, right) => left && right && left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
const isWholeMonthRange = (dateFrom, dateTo) => (
dateFrom
&& dateTo
&& isSameMonth(dateFrom, dateTo)
&& dateFrom.getDate() === 1
&& dateTo.getDate() === lastDayOfMonth(dateFrom)
);
const getOrderDate = (order) => parseLocalDate(order?.date ?? order?.created_at ?? order?.closed_at ?? order?.updated_at); const getOrderDate = (order) => parseLocalDate(order?.date ?? order?.created_at ?? order?.closed_at ?? order?.updated_at);
export const buildRelativeCollectionLabel = ({ export const buildRelativeCollectionLabel = ({
collectionId, collectionId,
orders = [], orders = [],
dateFrom = null,
dateTo = null,
collectionCount = 1,
locale = DEFAULT_LOCALE, locale = DEFAULT_LOCALE,
fallbackLabel = null, fallbackLabel = null,
} = {}) => { } = {}) => {
const rangeStart = parseLocalDate(dateFrom);
const rangeEnd = parseLocalDate(dateTo);
if (Number(collectionCount) === 1 && isWholeMonthRange(rangeStart, rangeEnd)) {
const monthLabel = formatMonthName(rangeStart, locale);
return {
label: monthLabel,
kind: "month",
monthLabel,
collectionId,
};
}
const orderDates = (orders || []) const orderDates = (orders || [])
.map(getOrderDate) .map(getOrderDate)
.filter(Boolean) .filter(Boolean)
@@ -247,7 +222,8 @@ export const makeOrderItemNode = (item, options = {}) => {
children: [], children: [],
selectable: true, selectable: true,
actionable: true, actionable: true,
icon: "fa-list-check", hideToggle: true,
hideIcon: true,
meta: { meta: {
item, item,
itemId, itemId,
@@ -331,6 +307,32 @@ export const buildOrderItemTree = (items = [], options = {}) => {
return roots; return roots;
}; };
const flattenOrderItemNodes = (nodes, depth = 0, parent = null) => nodes.flatMap((node) => [
{
...node,
isLeaf: true,
children: [],
meta: {
...node.meta,
tableDepth: depth,
isAssociatedItem: depth > 0,
associatedParentId: parent?.meta?.itemId || null,
associatedParentLabel: parent?.label || null,
},
},
...flattenOrderItemNodes(node.children || [], depth + 1, node),
]);
export const buildOrderItemTableRows = (items = [], options = {}) =>
flattenOrderItemNodes(buildOrderItemTree(items, options)).map((node, index) => ({
...node,
meta: {
...node.meta,
orderId: node.meta.orderId || toPositiveInteger(options.orderId),
showTableHeader: index === 0,
},
}));
export const classifyAttachment = (attachment) => { export const classifyAttachment = (attachment) => {
const rawName = normalizeString( const rawName = normalizeString(
attachment?.file_name attachment?.file_name
@@ -514,7 +516,6 @@ export const buildCollectionRootNodes = (customer, transactions = [], excludedOr
} }
}); });
const collectionCount = groups.size;
const nodes = Array.from(groups.entries()) const nodes = Array.from(groups.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.map(([collectionId, orders]) => { .map(([collectionId, orders]) => {
@@ -522,9 +523,6 @@ export const buildCollectionRootNodes = (customer, transactions = [], excludedOr
const relativeLabel = buildRelativeCollectionLabel({ const relativeLabel = buildRelativeCollectionLabel({
collectionId, collectionId,
orders, orders,
dateFrom: labels.dateFrom,
dateTo: labels.dateTo,
collectionCount,
locale: labels.locale, locale: labels.locale,
fallbackLabel, fallbackLabel,
}); });
@@ -1,7 +1,8 @@
import { import {
TREE_CATEGORY_TYPES, TREE_CATEGORY_TYPES,
TREE_NODE_TYPES, TREE_NODE_TYPES,
buildOrderItemTree, buildRelativeCollectionLabel,
buildOrderItemTableRows,
classifyAttachment, classifyAttachment,
getTreeAmount, getTreeAmount,
makeAttachmentNode, makeAttachmentNode,
@@ -78,6 +79,7 @@ export type InvoicingPeriodTreeSnapshot =
export interface SnapshotTreeLabels { export interface SnapshotTreeLabels {
collection: (id: number) => string; collection: (id: number) => string;
locale?: string;
ordersWithoutCollection: string; ordersWithoutCollection: string;
orders: string; orders: string;
items: string; items: string;
@@ -258,9 +260,10 @@ const buildBookingNode = (booking: Record<string, any>, labels: SnapshotTreeLabe
const buildCompleteOrderNode = (order: InvoicingPeriodSnapshotOrder, labels: SnapshotTreeLabels) => { const buildCompleteOrderNode = (order: InvoicingPeriodSnapshotOrder, labels: SnapshotTreeLabels) => {
const node = makeOrderNode(order, { label: labels.order(order.id) }); const node = makeOrderNode(order, { label: labels.order(order.id) });
node.meta.inSelectedPeriod = order.in_selected_period; node.meta.inSelectedPeriod = order.in_selected_period;
const itemNodes = buildOrderItemTree(order.items, { const itemNodes = buildOrderItemTableRows(order.items, {
fallbackLabel: (item: any, index: number) => labels.orderItemFallback(positiveInteger(item?.id) ?? index + 1), fallbackLabel: (item: any, index: number) => labels.orderItemFallback(positiveInteger(item?.id) ?? index + 1),
invoiceState: order.invoice_state, invoiceState: order.invoice_state,
orderId: order.id,
}); });
const attachmentGroups = new Map<string, any[]>([["certificate", []], ["image", []], ["other", []]]); const attachmentGroups = new Map<string, any[]>([["certificate", []], ["image", []], ["other", []]]);
order.attachments.forEach((attachment: any, index: number) => { order.attachments.forEach((attachment: any, index: number) => {
@@ -281,9 +284,6 @@ const buildCompleteOrderNode = (order: InvoicingPeriodSnapshotOrder, labels: Sna
return xlvaskNode; return xlvaskNode;
}); });
const categories = [ const categories = [
itemNodes.length > 0
? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, TREE_CATEGORY_TYPES.ORDER_ITEMS), labels.items, TREE_CATEGORY_TYPES.ORDER_ITEMS, node.type, node.id, itemNodes)
: null,
...(["certificate", "image", "other"] as const).map((kind) => { ...(["certificate", "image", "other"] as const).map((kind) => {
const children = attachmentGroups.get(kind) ?? []; const children = attachmentGroups.get(kind) ?? [];
const categoryType = { const categoryType = {
@@ -302,8 +302,8 @@ const buildCompleteOrderNode = (order: InvoicingPeriodSnapshotOrder, labels: Sna
? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, TREE_CATEGORY_TYPES.ORDER_XLVASK), labels.xlvask, TREE_CATEGORY_TYPES.ORDER_XLVASK, node.type, node.id, xlvaskNodes) ? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, TREE_CATEGORY_TYPES.ORDER_XLVASK), labels.xlvask, TREE_CATEGORY_TYPES.ORDER_XLVASK, node.type, node.id, xlvaskNodes)
: null, : null,
].filter(Boolean); ].filter(Boolean);
node.children = categories; node.children = [...itemNodes, ...categories];
node.isLeaf = categories.length === 0; node.isLeaf = node.children.length === 0;
return node; return node;
}; };
@@ -325,8 +325,16 @@ const genericObjectNode = (type: string, object: Record<string, any>, label: str
export const buildCompleteSnapshotRootNodes = (snapshot: CompleteInvoicingPeriodTreeSnapshot, labels: SnapshotTreeLabels) => { export const buildCompleteSnapshotRootNodes = (snapshot: CompleteInvoicingPeriodTreeSnapshot, labels: SnapshotTreeLabels) => {
const customer = snapshot.customer; const customer = snapshot.customer;
const collectionNodes = [...snapshot.collections].sort((left, right) => left.id - right.id).map((collection) => { const collectionNodes = [...snapshot.collections].sort((left, right) => left.id - right.id).map((collection) => {
const fallbackLabel = labels.collection(collection.id);
const relativeLabel = buildRelativeCollectionLabel({
collectionId: collection.id,
orders: collection.orders.filter((order) => order.in_selected_period),
locale: labels.locale,
fallbackLabel,
});
const node = makeCollectionNode(collection.id, collection.orders, customer, { const node = makeCollectionNode(collection.id, collection.orders, customer, {
label: labels.collection(collection.id), label: relativeLabel?.label || fallbackLabel,
relativeLabel,
collectionSummary: collection, collectionSummary: collection,
invoiceState: asString(collection.invoice_state ?? collection.state), invoiceState: asString(collection.invoice_state ?? collection.state),
completeOrderCount: collection.complete_order_count, completeOrderCount: collection.complete_order_count,
@@ -1757,7 +1757,8 @@ const getTransactionQueryParameters = () => {
display: grid; display: grid;
grid-template-areas: grid-template-areas:
"identity identity settings" "identity identity settings"
"attributes count amount" "count amount amount"
"attributes attributes attributes"
"activity state state"; "activity state state";
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto;
margin: 0 !important; margin: 0 !important;
@@ -1765,14 +1766,14 @@ const getTransactionQueryParameters = () => {
padding: 0.25rem; padding: 0.25rem;
} }
.period-customer-card__row > .column { min-width: 0; padding: 0.35rem; } .period-customer-card__row > .column { min-width: 0; padding: 0.35rem; }
.period-customer-card__identity { min-width: 0; } .period-customer-card__identity { min-width: 0; overflow: hidden; }
.period-customer-card__identity { grid-area: identity; } .period-customer-card__identity { grid-area: identity; }
.period-customer-card__attributes { grid-area: attributes; } .period-customer-card__attributes { grid-area: attributes; justify-self: stretch; width: 100%; }
.period-customer-card__activity { grid-area: activity; } .period-customer-card__activity { grid-area: activity; }
.period-customer-card__count { grid-area: count; white-space: nowrap; } .period-customer-card__count { grid-area: count; justify-self: end; white-space: nowrap; }
.period-customer-card__amount { grid-area: amount; white-space: nowrap; } .period-customer-card__amount { grid-area: amount; justify-self: end; white-space: nowrap; }
.period-customer-card__state { grid-area: state; justify-self: end; } .period-customer-card__state { grid-area: state; justify-self: end; }
.period-customer-card__settings { grid-area: settings; } .period-customer-card__settings { align-self: start; grid-area: settings; justify-self: end; }
.period-customer-card__selector { .period-customer-card__selector {
background: transparent; background: transparent;
border: 0; border: 0;
@@ -1788,6 +1789,13 @@ const getTransactionQueryParameters = () => {
.period-customer-card__selector:focus-visible { box-shadow: 0 0 0 3px rgba(50, 115, 220, 0.28); outline: none; } .period-customer-card__selector:focus-visible { box-shadow: 0 0 0 3px rgba(50, 115, 220, 0.28); outline: none; }
.period-customer-card__selector :deep(.color-indicator), .period-customer-card__selector :deep(.color-indicator),
.period-customer-card__selector :deep(.icon-text) { min-width: 0; } .period-customer-card__selector :deep(.icon-text) { min-width: 0; }
.period-customer-card__selector :deep(.icon-text) {
align-items: center;
display: grid;
gap: 0.35rem;
grid-template-columns: 1.25rem minmax(0, 1fr);
width: 100%;
}
.period-customer-card__selector :deep(.icon-text > span:last-child) { .period-customer-card__selector :deep(.icon-text > span:last-child) {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -1798,13 +1806,22 @@ const getTransactionQueryParameters = () => {
.period-customer-review { .period-customer-review {
align-items: center; align-items: center;
color: #485466; color: #485466;
display: flex; display: grid;
font-size: 0.72rem; font-size: 0.72rem;
gap: 0.25rem; gap: 0.25rem;
grid-template-columns: 1.25rem auto minmax(0, 1fr);
margin-top: 0.25rem; margin-top: 0.25rem;
min-width: 0;
} }
.period-customer-review__next { color: #6b7280; margin-left: 0.25rem; } .period-customer-review__next {
color: #6b7280;
margin-left: 0.25rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.review-state--red { color: #b42318; } .review-state--red { color: #b42318; }
.review-state--yellow { color: #9a6700; } .review-state--yellow { color: #9a6700; }
.review-state--blue { color: #175cd3; } .review-state--blue { color: #175cd3; }
@@ -1873,8 +1890,9 @@ const getTransactionQueryParameters = () => {
.period-customer-card__row { .period-customer-card__row {
grid-template-areas: grid-template-areas:
"identity identity settings" "identity identity settings"
"attributes count amount" "count amount amount"
"attributes state state"; "attributes attributes attributes"
"state state state";
} }
.period-customer-card__attributes { align-self: stretch; grid-area: attributes; } .period-customer-card__attributes { align-self: stretch; grid-area: attributes; }
.period-customer-card__activity { display: none; } .period-customer-card__activity { display: none; }
+291 -11
View File
@@ -167,8 +167,9 @@ function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
customer_number: 4101, customer_number: 4101,
customer_name: "Object Tree Logistics", customer_name: "Object Tree Logistics",
cashier_id: 7, cashier_id: 7,
cashier_name: "Copenhagen", cashier_name: "Jeppe",
department_id: 1, department_id: 1,
department_name: "Copenhagen",
invoice_collection_id: 3001, invoice_collection_id: 3001,
invoice_collection: { invoice_collection: {
id: 3001, id: 3001,
@@ -1465,6 +1466,107 @@ test.describe("Invoicing period tab", () => {
expect(periodRequests.length).toBeGreaterThan(0); expect(periodRequests.length).toBeGreaterThan(0);
}); });
test("@pr period view keeps every right-rail selector column aligned", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await openPeriodView(page);
const selectorNames = [
"all",
"possible_duplicates",
"invoice_per_order",
"fixed_pricing",
"special_arrangements",
"vehicle_subscriptions",
"tank_cleaning",
"self_wash",
];
const readRailGeometry = async () => {
const selectors = selectorNames.map((name) => page.getByTestId(`invoicing-period-view-selector-${name}`));
await Promise.all(selectors.map((selector) => expect(selector).toBeVisible()));
return page.getByTestId("invoicing-period-view-selectors").evaluate((navigation, names) => {
const readRect = (element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
centerY: rect.y + rect.height / 2,
};
};
const selectorsByName = names.map((name) => {
const selector = navigation.querySelector(`[data-testid="invoicing-period-view-selector-${name}"]`);
return {
name,
selector: readRect(selector),
row: readRect(selector.querySelector(".period-selector-row")),
icon: readRect(selector.querySelector(".period-selector-label .icon")),
label: readRect(selector.querySelector(".period-selector-label .icon-text > span:last-child")),
progress: readRect(selector.querySelector(".period-selector-progress-slot")),
chevron: readRect(selector.querySelector(".period-selector-chevron")),
};
});
const groups = [...navigation.querySelectorAll(".period-view-navigation__group")].map((group) => {
const heading = readRect(group.querySelector(".period-view-navigation__heading"));
const cards = [...group.querySelectorAll(".is-selector-view")].map(readRect);
return { heading, cards };
});
return { selectors: selectorsByName, groups };
}, selectorNames);
};
for (const viewportWidth of [1920, 1280]) {
await page.setViewportSize({ width: viewportWidth, height: 1080 });
const geometry = await readRailGeometry();
const first = geometry.selectors[0];
for (const item of geometry.selectors) {
expect(
Math.abs(item.selector.height - first.selector.height),
`${viewportWidth}px ${item.name} height`
).toBeLessThanOrEqual(1);
expect(Math.abs(item.row.x - first.row.x), `${viewportWidth}px ${item.name} row left`).toBeLessThanOrEqual(1);
expect(Math.abs(item.icon.x - first.icon.x), `${viewportWidth}px ${item.name} icon left`).toBeLessThanOrEqual(
1
);
expect(
Math.abs(item.label.x - first.label.x),
`${viewportWidth}px ${item.name} label left`
).toBeLessThanOrEqual(1);
expect(
Math.abs(item.progress.x - first.progress.x),
`${viewportWidth}px ${item.name} progress left`
).toBeLessThanOrEqual(1);
expect(
Math.abs(item.progress.width - first.progress.width),
`${viewportWidth}px ${item.name} progress width`
).toBeLessThanOrEqual(1);
expect(
Math.abs(item.chevron.x - first.chevron.x),
`${viewportWidth}px ${item.name} chevron left`
).toBeLessThanOrEqual(1);
expect(
Math.abs(item.row.centerY - item.selector.centerY),
`${viewportWidth}px ${item.name} vertical center`
).toBeLessThanOrEqual(1);
}
const cardGaps = [];
for (const group of geometry.groups) {
expect(
Math.abs(group.heading.x - group.cards[0].x),
`${viewportWidth}px heading left edge`
).toBeLessThanOrEqual(1);
for (let index = 1; index < group.cards.length; index += 1) {
cardGaps.push(group.cards[index].y - (group.cards[index - 1].y + group.cards[index - 1].height));
}
}
expect(Math.max(...cardGaps) - Math.min(...cardGaps), `${viewportWidth}px card gaps`).toBeLessThanOrEqual(1);
}
});
test("@smoke @pr period review workspace restores filters and supports keyboard master-detail navigation", async ({ test("@smoke @pr period review workspace restores filters and supports keyboard master-detail navigation", async ({
page, page,
}) => { }) => {
@@ -1601,6 +1703,14 @@ test.describe("Invoicing period tab", () => {
beforeGoto: (currentPage) => routeObjectTreeV2Endpoints(currentPage, state), beforeGoto: (currentPage) => routeObjectTreeV2Endpoints(currentPage, state),
}); });
await page.getByTestId("invoicing-period-view-selector-all").click(); await page.getByTestId("invoicing-period-view-selector-all").click();
const snapshotCollection = page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001");
await expect(snapshotCollection.locator(".invoice-period-tree-node__label-text")).toContainText(/1 Juli|1 July/i);
await expect(snapshotCollection.locator(".invoice-period-tree-node__label-text")).not.toContainText(
/Fakturasamling|Collection\s+#/i
);
await expect(snapshotCollection.locator(".invoice-period-tree-node__label-text")).not.toContainText(
/29 Juni|29 June/i
);
await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001")).toContainText( await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001")).toContainText(
/Valgt periode|Selected period/i /Valgt periode|Selected period/i
); );
@@ -1610,6 +1720,9 @@ test.describe("Invoicing period tab", () => {
await page.getByTestId("invoice-period-tree-toggle-all").click(); await page.getByTestId("invoice-period-tree-toggle-all").click();
await expect(page.getByTestId("invoice-period-tree-node-order:9002")).toBeVisible(); await expect(page.getByTestId("invoice-period-tree-node-order:9002")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-order_item:7701")).toBeVisible();
await expect(page.getByTestId("invoice-period-order-item-table-header-9001")).toHaveCount(1);
await expect(page.locator('[data-node-key="category:9001:order_items"]')).toHaveCount(0);
expect(orderItemRequests).toHaveLength(0); expect(orderItemRequests).toHaveLength(0);
const collectionNode = page.locator('[data-node-key="collected_order_invoice:3001"]'); const collectionNode = page.locator('[data-node-key="collected_order_invoice:3001"]');
@@ -1930,6 +2043,21 @@ test.describe("Invoicing period tab", () => {
await expect(customerRow.getByText(/Alle bogført|All booked/i)).toHaveCount(0); await expect(customerRow.getByText(/Alle bogført|All booked/i)).toHaveCount(0);
await expect(customerRow.getByText(/Spot Free/)).toHaveCount(0); await expect(customerRow.getByText(/Spot Free/)).toHaveCount(0);
const manualFlag = page.getByTestId("invoice-period-flag-501"); const manualFlag = page.getByTestId("invoice-period-flag-501");
const manualFlagBox = await getBoundingBox(manualFlag, "customer flag row");
const manualFlagMessage = manualFlag.locator(".invoice-period-flag-row__message");
const manualFlagMessageBox = await getBoundingBox(manualFlagMessage, "customer flag message");
expect(manualFlagMessageBox.width).toBeGreaterThan(manualFlagBox.width * 0.7);
expect(manualFlagMessageBox.x).toBeGreaterThan(manualFlagBox.x);
await expect(manualFlagMessage).toHaveCSS("text-align", "left");
const manualFlagActions = manualFlag.locator(".invoice-period-flag-row__actions");
await expect(manualFlagActions).toHaveCSS("opacity", "0");
await manualFlagActions.getByTitle(/Resolved|Løst/i).focus();
await expect(manualFlagActions).toHaveCSS("opacity", "1");
const revealedManualFlagMessageBox = await getBoundingBox(manualFlagMessage, "focused customer flag message");
const revealedManualFlagActionsBox = await getBoundingBox(manualFlagActions, "focused customer flag actions");
expect(revealedManualFlagActionsBox.y).toBeGreaterThanOrEqual(
revealedManualFlagMessageBox.y + revealedManualFlagMessageBox.height - 1
);
await manualFlag.locator(".invoice-period-flag-row__icon").hover(); await manualFlag.locator(".invoice-period-flag-row__icon").hover();
const manualFlagTooltip = page.getByTestId("invoice-period-flag-created-tooltip-501"); const manualFlagTooltip = page.getByTestId("invoice-period-flag-created-tooltip-501");
await expect(manualFlagTooltip).toContainText("Oprettet"); await expect(manualFlagTooltip).toContainText("Oprettet");
@@ -2143,6 +2271,38 @@ test.describe("Invoicing period tab", () => {
await expandTreeNode(page, "category:3001:collection_orders"); await expandTreeNode(page, "category:3001:collection_orders");
await expandTreeNode(page, "order:9001"); await expandTreeNode(page, "order:9001");
const collectionFieldRows = await Promise.all(
[
["state", "po_number", "notes", "booked_invoice_id"],
["external_id", "closed_at", "processor", "total_net_amount"],
["error_message", "created_at", "updated_at"],
].map((fields) =>
Promise.all(
fields.map((field) =>
getBoundingBox(
page.getByTestId(`invoice-period-tree-field-collected_order_invoice:3001-${field}`),
`collection ${field}`
)
)
)
)
);
collectionFieldRows.forEach((row, rowIndex) => {
const rowTops = row.map((box) => Math.round(box.y));
expect(
Math.max(...rowTops) - Math.min(...rowTops),
`collection metadata row ${rowIndex + 1}`
).toBeLessThanOrEqual(2);
});
const primaryCollectionFields = [...collectionFieldRows[0], ...collectionFieldRows[1]];
const primaryCollectionWidths = primaryCollectionFields.map((box) => Math.round(box.width));
expect(Math.max(...primaryCollectionWidths) - Math.min(...primaryCollectionWidths)).toBeLessThanOrEqual(2);
expect(Math.min(...primaryCollectionWidths)).toBeGreaterThanOrEqual(60);
const usesCompactMetadataGrid = collectionFieldRows[1][0].y > collectionFieldRows[0][0].y + 2;
const lastPrimaryRowY = usesCompactMetadataGrid ? collectionFieldRows[1][0].y : collectionFieldRows[0][0].y;
expect(collectionFieldRows[2][0].y).toBeGreaterThan(lastPrimaryRowY);
expect(collectionFieldRows[2][0].width).toBeGreaterThan(collectionFieldRows[2][1].width * 1.8);
const wheelLocators = [ const wheelLocators = [
page.getByTestId("invoice-period-tree-action-wheel-collected_order_invoice:3001"), page.getByTestId("invoice-period-tree-action-wheel-collected_order_invoice:3001"),
page.getByTestId("invoice-period-tree-action-wheel-order:9001"), page.getByTestId("invoice-period-tree-action-wheel-order:9001"),
@@ -2156,6 +2316,10 @@ test.describe("Invoicing period tab", () => {
expect(Math.max(...wheelRightEdges) - Math.min(...wheelRightEdges)).toBeLessThanOrEqual(6); expect(Math.max(...wheelRightEdges) - Math.min(...wheelRightEdges)).toBeLessThanOrEqual(6);
await expect(page.getByTestId("invoice-period-tree-field-order:9001-notes")).toBeVisible(); await expect(page.getByTestId("invoice-period-tree-field-order:9001-notes")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-department")).toContainText(
/Afdeling|Department/i
);
await expect(page.getByTestId("invoice-period-tree-field-value-order:9001-department")).toHaveText("Copenhagen");
await expect(page.getByTestId("invoice-period-tree-field-order:9001-registrations")).toBeVisible(); await expect(page.getByTestId("invoice-period-tree-field-order:9001-registrations")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_2")).toBeVisible(); await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_2")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_3")).toHaveCount(0); await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_3")).toHaveCount(0);
@@ -2164,13 +2328,33 @@ test.describe("Invoicing period tab", () => {
await expect(page.getByTestId("invoice-period-tree-node-order:9001")).toContainText("Vask #9001"); await expect(page.getByTestId("invoice-period-tree-node-order:9001")).toContainText("Vask #9001");
await expect(page.locator("[data-testid$='-product_id']")).toHaveCount(0); await expect(page.locator("[data-testid$='-product_id']")).toHaveCount(0);
const fieldBoxes = await Promise.all( const firstOrderFieldRow = await Promise.all(
["reference", "notes", "po", "registrations", "include_in_invoice", "total_net_amount"].map((field) => ["reference", "po", "notes", "department"].map((field) =>
getBoundingBox(page.getByTestId(`invoice-period-tree-field-order:9001-${field}`), field) getBoundingBox(page.getByTestId(`invoice-period-tree-field-order:9001-${field}`), field)
) )
); );
const fieldTops = fieldBoxes.map((box) => Math.round(box.y)); const firstOrderFieldTops = firstOrderFieldRow.map((box) => Math.round(box.y));
expect(Math.max(...fieldTops) - Math.min(...fieldTops)).toBeLessThanOrEqual(2); expect(Math.max(...firstOrderFieldTops) - Math.min(...firstOrderFieldTops)).toBeLessThanOrEqual(2);
const firstOrderFieldWidths = firstOrderFieldRow.map((box) => Math.round(box.width));
expect(Math.max(...firstOrderFieldWidths) - Math.min(...firstOrderFieldWidths)).toBeLessThanOrEqual(2);
const registrationBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order:9001-registrations"),
"registrations"
);
const includeInInvoiceBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order:9001-include_in_invoice"),
"include in invoice"
);
const orderTotalBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order:9001-total_net_amount"),
"order total"
);
expect(Math.abs(registrationBox.y - includeInInvoiceBox.y)).toBeLessThanOrEqual(2);
expect(Math.abs(orderTotalBox.y - includeInInvoiceBox.y)).toBeLessThanOrEqual(2);
expect(registrationBox.y).toBeGreaterThan(firstOrderFieldRow[0].y);
expect(registrationBox.width).toBeGreaterThan(includeInInvoiceBox.width * 1.8);
expect(Math.abs(orderTotalBox.width - includeInInvoiceBox.width)).toBeLessThanOrEqual(2);
const quantityBox = await getBoundingBox( const quantityBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order_item:7701-quantity"), page.getByTestId("invoice-period-tree-field-order_item:7701-quantity"),
@@ -2178,11 +2362,29 @@ test.describe("Invoicing period tab", () => {
); );
const priceBox = await getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "price"); const priceBox = await getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "price");
const firstOrderItem = page.getByTestId("invoice-period-tree-node-order_item:7701"); const firstOrderItem = page.getByTestId("invoice-period-tree-node-order_item:7701");
const associatedOrderItem = page.getByTestId("invoice-period-tree-node-order_item:7702");
const itemTableHeader = page.getByTestId("invoice-period-order-item-table-header-9001");
await expect(firstOrderItem.locator(".invoice-period-tree-node__subtitle")).toHaveCount(0); await expect(firstOrderItem.locator(".invoice-period-tree-node__subtitle")).toHaveCount(0);
await expect(page.getByTestId("invoice-period-tree-field-order_item:7701-quantity")).toContainText( await expect(itemTableHeader).toHaveCount(1);
/Antal|Quantity/i await expect(itemTableHeader).toContainText(/Produkt|Product/i);
); await expect(itemTableHeader).toContainText(/Antal|Quantity/i);
await expect(page.getByTestId("invoice-period-tree-field-order_item:7701-price")).toContainText(/Pris|Price/i); await expect(itemTableHeader).toContainText(/Pris|Price/i);
await expect(
page
.getByTestId("invoice-period-tree-field-order_item:7701-quantity")
.locator(".invoice-period-order-item-table__value")
).not.toContainText(/Antal|Quantity/i);
await expect(
page
.getByTestId("invoice-period-tree-field-order_item:7701-price")
.locator(".invoice-period-order-item-table__value")
).not.toContainText(/Pris|Price/i);
await expect(
page.getByTestId("invoice-period-tree-field-order_item:7701-quantity").locator(".is-sr-only")
).toContainText(/Antal|Quantity/i);
await expect(
page.getByTestId("invoice-period-tree-field-order_item:7702-reference").locator(".is-sr-only")
).toContainText(/Reference/i);
expect(quantityBox.x + quantityBox.width).toBeLessThanOrEqual(priceBox.x + 2); expect(quantityBox.x + quantityBox.width).toBeLessThanOrEqual(priceBox.x + 2);
const alignedAmountBoxes = await Promise.all([ const alignedAmountBoxes = await Promise.all([
getBoundingBox( getBoundingBox(
@@ -2194,12 +2396,53 @@ test.describe("Invoicing period tab", () => {
]); ]);
const amountRightEdges = alignedAmountBoxes.map((box) => Math.round(box.x + box.width)); const amountRightEdges = alignedAmountBoxes.map((box) => Math.round(box.x + box.width));
expect(Math.max(...amountRightEdges) - Math.min(...amountRightEdges)).toBeLessThanOrEqual(8); expect(Math.max(...amountRightEdges) - Math.min(...amountRightEdges)).toBeLessThanOrEqual(8);
await expandTreeNode(page, "order_item:7701"); await expect(associatedOrderItem).toBeVisible();
await expect(page.locator('[data-node-key="order_item:7701"] [data-node-key="order_item:7702"]')).toBeVisible(); await expect(
page.locator('[data-node-key="order:9001"] > .b-tree-children > [data-node-key="order_item:7702"]')
).toBeVisible();
await expect(page.locator('[data-node-key="category:9001:order_items"]')).toHaveCount(0);
await expect(
page.locator('[data-node-key="order_item:7701"] > .b-tree-node-content > .b-tree-node-icon')
).toHaveCount(0);
await expect(
page.locator('[data-node-key="order_item:7702"] > .b-tree-node-content > .b-tree-node-icon')
).toHaveCount(0);
await expect(
page.locator('[data-node-key="order_item:7701"] > .b-tree-node-content > .b-tree-node-toggle')
).toHaveCount(0);
await expect(
page.locator('[data-node-key="order_item:7702"] > .b-tree-node-content > .b-tree-node-toggle')
).toHaveCount(0);
await expect(associatedOrderItem.locator(".invoice-period-order-item-table__relation")).toHaveText("+");
const associatedItemAriaSnapshot = await page.locator('[data-node-key="order_item:7702"]').ariaSnapshot();
expect(associatedItemAriaSnapshot).toMatch(/Tilknyttet|Associated/i);
expect(associatedItemAriaSnapshot).toContain("Forvogn med ekstra langt produktnavn til visuel afkortning");
await expect( await expect(
page.getByTestId("invoice-period-tree-node-order_item:7701").locator(".invoice-period-tree-node__label-text") page.getByTestId("invoice-period-tree-node-order_item:7701").locator(".invoice-period-tree-node__label-text")
).toHaveCSS("white-space", "normal"); ).toHaveCSS("white-space", "normal");
const rootItemColumns = await Promise.all(
["reference", "notes", "quantity", "price"].map((field) =>
getBoundingBox(page.getByTestId(`invoice-period-tree-field-order_item:7701-${field}`), `root item ${field}`)
)
);
const associatedItemColumns = await Promise.all(
["reference", "notes", "quantity", "price"].map((field) =>
getBoundingBox(
page.getByTestId(`invoice-period-tree-field-order_item:7702-${field}`),
`associated item ${field}`
)
)
);
rootItemColumns.forEach((box, index) => {
expect(Math.abs(box.x - associatedItemColumns[index].x)).toBeLessThanOrEqual(2);
expect(Math.abs(box.width - associatedItemColumns[index].width)).toBeLessThanOrEqual(2);
});
await page.getByTestId("invoice-period-order-item-table-select-all-9001").check();
await expect(page.locator('[data-node-key="order_item:7701"]')).toHaveAttribute("aria-checked", "true");
await expect(page.locator('[data-node-key="order_item:7702"]')).toHaveAttribute("aria-checked", "true");
const orderWheelRoot = page.getByTestId("invoice-period-tree-action-wheel-order:9001"); const orderWheelRoot = page.getByTestId("invoice-period-tree-action-wheel-order:9001");
await orderWheelRoot.locator(".action-settings-wheel-trigger").click(); await orderWheelRoot.locator(".action-settings-wheel-trigger").click();
await expect(orderWheelRoot.getByTestId("action-settings-wheel-section-order")).toBeVisible(); await expect(orderWheelRoot.getByTestId("action-settings-wheel-section-order")).toBeVisible();
@@ -2234,6 +2477,17 @@ test.describe("Invoicing period tab", () => {
}); });
expect(objectLineMetrics.mainHeight).toBeLessThanOrEqual(objectLineMetrics.mainLineHeight * 1.6); expect(objectLineMetrics.mainHeight).toBeLessThanOrEqual(objectLineMetrics.mainLineHeight * 1.6);
expect(objectLineMetrics.subtitleHeight).toBeLessThanOrEqual(objectLineMetrics.subtitleLineHeight * 1.6); expect(objectLineMetrics.subtitleHeight).toBeLessThanOrEqual(objectLineMetrics.subtitleLineHeight * 1.6);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByTestId("invoice-period-tree-toolbar")).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
const itemScrollMetrics = await page.locator(".invoice-period-object-tree__scroll").evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(itemScrollMetrics.scrollWidth).toBeGreaterThan(itemScrollMetrics.clientWidth);
}); });
test("@smoke period expanded order object tree omits table filters", async ({ page }) => { test("@smoke period expanded order object tree omits table filters", async ({ page }) => {
@@ -2333,6 +2587,26 @@ test.describe("Invoicing period tab", () => {
const multiCardBox = await getBoundingBox(multiAttributeCard, "multi-attribute customer card"); const multiCardBox = await getBoundingBox(multiAttributeCard, "multi-attribute customer card");
const singleCardBox = await getBoundingBox(singleAttributeCard, "single-attribute customer card"); const singleCardBox = await getBoundingBox(singleAttributeCard, "single-attribute customer card");
const stackBox = await getBoundingBox(attributeStack, "customer attribute stack"); const stackBox = await getBoundingBox(attributeStack, "customer attribute stack");
const identityBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__identity"),
"customer identity"
);
const selectorBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__selector"),
"customer selector"
);
const settingsBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__settings"),
"customer settings"
);
const countBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__count"),
"customer transaction count"
);
const amountBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__amount"),
"customer amount"
);
const tagBoxes = []; const tagBoxes = [];
for (const [index, tagLocator] of tagLocators.entries()) { for (const [index, tagLocator] of tagLocators.entries()) {
@@ -2346,6 +2620,12 @@ test.describe("Invoicing period tab", () => {
} }
expect(Math.abs(multiCardBox.height - singleCardBox.height)).toBeLessThanOrEqual(3); expect(Math.abs(multiCardBox.height - singleCardBox.height)).toBeLessThanOrEqual(3);
expect(identityBox.x).toBeLessThan(settingsBox.x);
expect(settingsBox.x + settingsBox.width).toBeLessThanOrEqual(multiCardBox.x + multiCardBox.width);
expect(Math.abs(countBox.y - amountBox.y)).toBeLessThanOrEqual(2);
expect(stackBox.y).toBeGreaterThan(countBox.y);
expect(Math.abs(stackBox.x - selectorBox.x)).toBeLessThanOrEqual(2);
expect(stackBox.width).toBeGreaterThan(multiCardBox.width * 0.8);
tagBoxes.slice(1).forEach((tagBox, index) => { tagBoxes.slice(1).forEach((tagBox, index) => {
expect(Math.abs(tagBox.x - tagBoxes[0].x)).toBeLessThanOrEqual(1); expect(Math.abs(tagBox.x - tagBoxes[0].x)).toBeLessThanOrEqual(1);
+18
View File
@@ -27,6 +27,24 @@ vi.mock("buefy", () => ({
})); }));
describe("BuefyTree checkbox selection", () => { describe("BuefyTree checkbox selection", () => {
it("removes the icon wrapper only for nodes that explicitly opt out", () => {
const wrapper = mount(BuefyTree, {
props: {
data: [
{ id: "default-icon", label: "Default icon", isLeaf: true },
{ id: "compact", label: "Compact", isLeaf: true, hideIcon: true, hideToggle: true },
],
},
});
expect(wrapper.find('[data-node-key="default-icon"] > .b-tree-node-content .b-tree-node-icon').exists()).toBe(true);
expect(wrapper.find('[data-node-key="compact"] > .b-tree-node-content .b-tree-node-icon').exists()).toBe(false);
expect(wrapper.find('[data-node-key="default-icon"] > .b-tree-node-content .b-tree-node-toggle').exists()).toBe(
true
);
expect(wrapper.find('[data-node-key="compact"] > .b-tree-node-content .b-tree-node-toggle').exists()).toBe(false);
});
it("uses non-selectable checkable categories as lazy select-all branch controllers", async () => { it("uses non-selectable checkable categories as lazy select-all branch controllers", async () => {
const load = vi.fn(async () => [ const load = vi.fn(async () => [
{ id: "order:70128", label: "Ordre #70128", selectable: true, isLeaf: true }, { id: "order:70128", label: "Ordre #70128", selectable: true, isLeaf: true },
+72 -22
View File
@@ -111,6 +111,10 @@ vi.mock("vue-i18n", () => ({
}), }),
})); }));
vi.mock("@/components/pagination/departmentTabs.vue", () => ({
getDepartmentName: (id) => (Number(id) === 75 ? "Department 75" : String(id)),
}));
vi.mock("@/components/displays/buttons/EditableTableColumn.vue", () => ({ vi.mock("@/components/displays/buttons/EditableTableColumn.vue", () => ({
default: { default: {
name: "EditableTableColumn", name: "EditableTableColumn",
@@ -381,7 +385,27 @@ describe("InvoicingPeriodObjectTree", () => {
}); });
}); });
it("auto-expands order items when an order node is expanded", async () => { it("refreshes a legacy collection date label when an existing order date changes", async () => {
const firstOrder = {
...mocks.order,
created_at: "2026-06-01 10:00:00",
booked: true,
};
const wrapper = mountTree({ transactions: [firstOrder] });
const collectionLabel = () =>
wrapper.get('[data-node-key="collected_order_invoice:3001"] .invoice-period-tree-node__label-text');
expect(collectionLabel().text()).toContain("1 Juni");
await wrapper.setProps({
transactions: [{ ...firstOrder, created_at: "2026-06-30 10:00:00" }],
});
await flushPromises();
expect(collectionLabel().text()).toContain("30 Juni");
});
it("shows order items directly when an order node is expanded", async () => {
const wrapper = mountTree(); const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001"); await expandNode(wrapper, "collected_order_invoice:3001");
@@ -389,11 +413,14 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "order:9001"); await expandNode(wrapper, "order:9001");
expect(mocks.request).toHaveBeenCalledWith("/order/items", "GET", { order_id: 9001 }); expect(mocks.request).toHaveBeenCalledWith("/order/items", "GET", { order_id: 9001 });
expect(wrapper.find('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded"); expect(wrapper.find('[data-node-key="category:9001:order_items"]').exists()).toBe(false);
expect(
wrapper.find('[data-node-key="order:9001"] > .b-tree-children > [data-node-key="order_item:501"]').exists()
).toBe(true);
expect(wrapper.text()).toContain("Premium wash"); expect(wrapper.text()).toContain("Premium wash");
}); });
it("shows order-item quantity and price once through the standard editable fields", async () => { it("uses one shared table header instead of repeating order-item field labels", async () => {
const wrapper = mountTree(); const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001"); await expandNode(wrapper, "collected_order_invoice:3001");
@@ -405,12 +432,22 @@ describe("InvoicingPeriodObjectTree", () => {
const quantity = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-quantity"]'); const quantity = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-quantity"]');
const price = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-price"]'); const price = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-price"]');
expect(quantity.text()).toContain("Antal"); const header = wrapper.get('[data-testid="invoice-period-order-item-table-header-9001"]');
expect(quantity.text()).toContain("2"); expect(header.text()).toContain("Produkt");
expect(price.text()).toContain("Pris"); expect(header.text()).toContain("Antal");
expect(price.text()).toContain("120 DKK"); expect(header.text()).toContain("Pris");
expect(quantity.get(".invoice-period-order-item-table__value").text()).toBe("2");
expect(price.get(".invoice-period-order-item-table__value").text()).toBe("120 DKK");
expect(quantity.find(".invoice-period-tree-node-field__label").exists()).toBe(false);
expect(price.find(".invoice-period-tree-node-field__label").exists()).toBe(false);
expect(quantity.get(".is-sr-only").text()).toBe("Antal:");
expect(price.get(".is-sr-only").text()).toBe("Pris:");
expect(quantity.find(".editable-table-column-stub").exists()).toBe(true); expect(quantity.find(".editable-table-column-stub").exists()).toBe(true);
expect(price.find(".editable-table-column-stub").exists()).toBe(true); expect(price.find(".editable-table-column-stub").exists()).toBe(true);
await wrapper.get('[data-testid="invoice-period-order-item-table-select-all-9001"]').setValue(true);
await flushPromises();
expect(wrapper.get('[data-node-key="order_item:501"]').attributes("aria-checked")).toBe("true");
}); });
it("preserves untouched snapshot item values during an inline edit", async () => { it("preserves untouched snapshot item values during an inline edit", async () => {
@@ -429,7 +466,6 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "collected_order_invoice:3001"); await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders"); await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001"); await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click"); await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises(); await flushPromises();
@@ -457,7 +493,6 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "collected_order_invoice:3001"); await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders"); await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001"); await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click"); await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises(); await flushPromises();
@@ -484,7 +519,6 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "collected_order_invoice:3001"); await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders"); await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001"); await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click"); await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises(); await flushPromises();
@@ -513,7 +547,8 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).toContain("is-expanded"); expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:3001:collection_orders"]').classes()).toContain("is-expanded"); expect(wrapper.get('[data-node-key="category:3001:collection_orders"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded"); expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded"); expect(wrapper.find('[data-node-key="category:9001:order_items"]').exists()).toBe(false);
expect(wrapper.get('[data-node-key="order_item:501"]').exists()).toBe(true);
expect(toggle().text()).toContain("Fold alle sammen"); expect(toggle().text()).toContain("Fold alle sammen");
expect(toggle().attributes("aria-pressed")).toBe("true"); expect(toggle().attributes("aria-pressed")).toBe("true");
@@ -536,7 +571,8 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.get('[data-testid="invoice-period-tree-toggle-all"]').text()).toContain("Fold alle sammen"); expect(wrapper.get('[data-testid="invoice-period-tree-toggle-all"]').text()).toContain("Fold alle sammen");
expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded"); expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded"); expect(wrapper.find('[data-node-key="category:9001:order_items"]').exists()).toBe(false);
expect(wrapper.get('[data-node-key="order_item:501"]').exists()).toBe(true);
}); });
it("renders row action wheels for actionable collection, order, and order item nodes", async () => { it("renders row action wheels for actionable collection, order, and order item nodes", async () => {
@@ -580,7 +616,7 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.find('[data-testid^="invoice-period-tree-field-empty-toggle-"]').exists()).toBe(false); expect(wrapper.find('[data-testid^="invoice-period-tree-field-empty-toggle-"]').exists()).toBe(false);
}); });
it("uses wash labels, nests related order items, and omits product ids and type tags", async () => { it("uses wash labels and aligns associated order items as direct table rows", async () => {
mocks.orderItems.push({ mocks.orderItems.push({
id: 502, id: 502,
order_id: 9001, order_id: 9001,
@@ -595,10 +631,23 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "collected_order_invoice:3001"); await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders"); await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001"); await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "order_item:501");
expect(wrapper.get('[data-node-key="order:9001"]').text()).toContain("Vask #9001"); expect(wrapper.get('[data-node-key="order:9001"]').text()).toContain("Vask #9001");
expect(wrapper.find('[data-node-key="order_item:501"] [data-node-key="order_item:502"]').exists()).toBe(true); expect(
wrapper.find('[data-node-key="order:9001"] > .b-tree-children > [data-node-key="order_item:502"]').exists()
).toBe(true);
expect(
wrapper
.get('[data-testid="invoice-period-order-item-table-row-502"] .invoice-period-order-item-table__relation')
.text()
).toBe("+");
expect(wrapper.get('[data-testid="invoice-period-order-item-table-row-502"] .is-sr-only').text()).toContain(
"Premium wash"
);
expect(wrapper.findAll('[data-testid="invoice-period-order-item-table-header-9001"]')).toHaveLength(1);
expect(wrapper.get('[data-testid="invoice-period-tree-field-order_item:502-reference"] .is-sr-only').text()).toBe(
"Reference:"
);
expect(wrapper.text()).toContain("Interior wash with a complete descriptive product label"); expect(wrapper.text()).toContain("Interior wash with a complete descriptive product label");
expect(wrapper.find('[data-testid$="-product_id"]').exists()).toBe(false); expect(wrapper.find('[data-testid$="-product_id"]').exists()).toBe(false);
expect(wrapper.find(".tag.is-white").exists()).toBe(false); expect(wrapper.find(".tag.is-white").exists()).toBe(false);
@@ -791,7 +840,7 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.text()).not.toContain("E-conomic kladde #88"); expect(wrapper.text()).not.toContain("E-conomic kladde #88");
}); });
it("colors collection, invoice, order, and item icons from the booked state", async () => { it("colors structural icons from the booked state and omits the redundant order-item icon", async () => {
const wrapper = mountTree(); const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001"); await expandNode(wrapper, "collected_order_invoice:3001");
@@ -816,12 +865,13 @@ describe("InvoicingPeriodObjectTree", () => {
expect( expect(
wrapper.get('[data-node-key="order:9001"] > .b-tree-node-content .b-tree-node-icon span').classes() wrapper.get('[data-node-key="order:9001"] > .b-tree-node-content .b-tree-node-icon span').classes()
).toContain("has-text-success"); ).toContain("has-text-success");
expect( expect(wrapper.find('[data-node-key="category:9001:order_items"]').exists()).toBe(false);
wrapper.get('[data-node-key="category:9001:order_items"] > .b-tree-node-content .b-tree-node-icon span').classes() expect(wrapper.find('[data-node-key="order_item:501"] > .b-tree-node-content .b-tree-node-icon').exists()).toBe(
).toContain("has-text-success"); false
expect( );
wrapper.get('[data-node-key="order_item:501"] > .b-tree-node-content .b-tree-node-icon span').classes() expect(wrapper.find('[data-node-key="order_item:501"] > .b-tree-node-content .b-tree-node-toggle').exists()).toBe(
).toContain("has-text-success"); false
);
}); });
it("downloads booked e-conomic invoice pdfs for booked invoice nodes", async () => { it("downloads booked e-conomic invoice pdfs for booked invoice nodes", async () => {
+32 -5
View File
@@ -4,6 +4,7 @@ import {
TREE_NODE_TYPES, TREE_NODE_TYPES,
buildRelativeCollectionLabel, buildRelativeCollectionLabel,
buildCollectionRootNodes, buildCollectionRootNodes,
buildOrderItemTableRows,
buildOrderItemTree, buildOrderItemTree,
classifyAttachment, classifyAttachment,
getTreeAmount, getTreeAmount,
@@ -66,7 +67,7 @@ describe("invoicing period tree node builders", () => {
}); });
}); });
it("uses relative collection labels for whole-month and multi-collection ranges", () => { it("uses order date ranges for both whole-month and multi-collection views", () => {
const wholeMonthNodes = buildCollectionRootNodes( const wholeMonthNodes = buildCollectionRootNodes(
{ customer_number: 2001, customer_name: "ACME" }, { customer_number: 2001, customer_name: "ACME" },
[ [
@@ -82,10 +83,11 @@ describe("invoicing period tree node builders", () => {
} }
); );
expect(wholeMonthNodes[0].label).toBe("Juni"); expect(wholeMonthNodes[0].label).toBe("2 Juni → 18 Juni");
expect(wholeMonthNodes[0].meta.relativeLabel).toMatchObject({ expect(wholeMonthNodes[0].meta.relativeLabel).toMatchObject({
kind: "month", kind: "range",
monthLabel: "Juni", startLabel: "2 Juni",
endLabel: "18 Juni",
}); });
const rangeNodes = buildCollectionRootNodes( const rangeNodes = buildCollectionRootNodes(
@@ -304,7 +306,13 @@ describe("invoicing period tree node builders", () => {
washId: "W-12", washId: "W-12",
}, },
}); });
expect(itemNode).toMatchObject({ type: TREE_NODE_TYPES.ORDER_ITEM, isLeaf: true, actionable: true }); expect(itemNode).toMatchObject({
type: TREE_NODE_TYPES.ORDER_ITEM,
isLeaf: true,
actionable: true,
hideToggle: true,
hideIcon: true,
});
expect(attachmentNode).toMatchObject({ expect(attachmentNode).toMatchObject({
type: TREE_NODE_TYPES.ATTACHMENT, type: TREE_NODE_TYPES.ATTACHMENT,
meta: { attachmentId: 8, orderId: 91, attachmentType: "image" }, meta: { attachmentId: 8, orderId: 91, attachmentType: "image" },
@@ -347,6 +355,25 @@ describe("invoicing period tree node builders", () => {
expect(nodes.slice(1).every((node) => node.isLeaf)).toBe(true); expect(nodes.slice(1).every((node) => node.isLeaf)).toBe(true);
}); });
it("flattens associated order items into stable direct table rows with one shared header", () => {
const rows = buildOrderItemTableRows(
[
{ id: 501, product_name: "Wash" },
{ id: 502, related_item_id: 501, product_name: "Interior" },
{ id: 503, related_item_id: 502, product_name: "Mat cleaning" },
{ id: 504, related_item_id: 999, product_name: "Orphan" },
{ id: 505, related_item_id: 506, product_name: "Cycle A" },
{ id: 506, related_item_id: 505, product_name: "Cycle B" },
],
{ orderId: 91 }
);
expect(rows.map((node) => node.meta.itemId)).toEqual([501, 502, 503, 504, 505, 506]);
expect(rows.map((node) => node.meta.tableDepth)).toEqual([0, 1, 2, 0, 0, 0]);
expect(rows.map((node) => node.meta.showTableHeader)).toEqual([true, false, false, false, false, false]);
expect(rows.every((node) => node.isLeaf && node.children.length === 0 && node.meta.orderId === 91)).toBe(true);
});
it("creates economic invoice nodes with draft/booked metadata", () => { it("creates economic invoice nodes with draft/booked metadata", () => {
const node = makeEconomicInvoiceNode({ const node = makeEconomicInvoiceNode({
collectionId: 3001, collectionId: 3001,
@@ -35,7 +35,10 @@ const payload = (revision = "rev-1") => ({
invoice_collection_id: 3001, invoice_collection_id: 3001,
in_selected_period: true, in_selected_period: true,
total_net_amount: 200, total_net_amount: 200,
items: [{ id: 501, order_id: 9001, product_name: "Wash", quantity: 1, price: 200 }], items: [
{ id: 501, product_name: "Wash", quantity: 1, price: 200 },
{ id: 502, related_item_id: 501, product_name: "Interior", quantity: 1, price: 25 },
],
attachments: [], attachments: [],
bookings: [], bookings: [],
xlvask: [], xlvask: [],
@@ -79,6 +82,25 @@ const labels = {
}; };
describe("invoicing period selected-customer tree snapshot", () => { describe("invoicing period selected-customer tree snapshot", () => {
it("keeps date-range collection labels when building the complete snapshot tree", () => {
const rangePayload = payload("rev-range");
rangePayload.date_from = "2026-01-01";
rangePayload.date_to = "2026-01-31";
rangePayload.collections[0].orders[0].created_at = "2026-01-01 10:00:00";
rangePayload.collections[0].orders[1].created_at = "2026-01-31 10:00:00";
rangePayload.collections[0].orders[1].in_selected_period = true;
const snapshot = normalizeInvoicingPeriodTreeSnapshot({ data: { data: rangePayload } });
const roots = buildCompleteSnapshotRootNodes(snapshot, { ...labels, locale: "da-DK" });
expect(roots[0].label).toBe("1 Januar → 31 Januar");
expect(roots[0].meta.relativeLabel).toMatchObject({
kind: "range",
startLabel: "1 Januar",
endLabel: "31 Januar",
});
});
it("normalizes a complete capability-gated snapshot and keeps off-period orders in the complete collection", () => { it("normalizes a complete capability-gated snapshot and keeps off-period orders in the complete collection", () => {
const snapshot = normalizeInvoicingPeriodTreeSnapshot({ data: { data: payload() } }); const snapshot = normalizeInvoicingPeriodTreeSnapshot({ data: { data: payload() } });
expect(snapshot).toMatchObject({ expect(snapshot).toMatchObject({
@@ -101,7 +123,15 @@ describe("invoicing period selected-customer tree snapshot", () => {
const orderNodes = roots[0].children[0].children; const orderNodes = roots[0].children[0].children;
expect(orderNodes.map((node) => node.id)).toEqual(["order:9001", "order:9002"]); expect(orderNodes.map((node) => node.id)).toEqual(["order:9001", "order:9002"]);
expect(orderNodes[1].meta.inSelectedPeriod).toBe(false); expect(orderNodes[1].meta.inSelectedPeriod).toBe(false);
expect(orderNodes[0].children[0].children[0].id).toBe("order_item:501"); expect(orderNodes[0].children[0].id).toBe("order_item:501");
expect(orderNodes[0].children[0].meta.showTableHeader).toBe(true);
expect(orderNodes[0].children[0].meta.orderId).toBe(9001);
expect(orderNodes[0].children[1]).toMatchObject({
id: "order_item:502",
isLeaf: true,
meta: { orderId: 9001, tableDepth: 1, showTableHeader: false },
});
expect(orderNodes[0].children.some((node) => node.category === "order_items")).toBe(false);
}); });
it("rejects incomplete or capability-disabled payloads", () => { it("rejects incomplete or capability-disabled payloads", () => {
+36 -1
View File
@@ -104,6 +104,10 @@ const periodObjectTreeSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue"), join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue"),
"utf8" "utf8"
); );
const periodFlagListSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue"),
"utf8"
);
const periodTreeSnapshotSource = readFileSync( const periodTreeSnapshotSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts"), join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts"),
"utf8" "utf8"
@@ -440,6 +444,9 @@ describe("Periode tab contract", () => {
it("stacks grouped selectors in the desktop rail and keeps compact navigation responsive", () => { it("stacks grouped selectors in the desktop rail and keeps compact navigation responsive", () => {
expect(periodRightSource).toContain("grid-template-columns: minmax(0, 1fr);"); expect(periodRightSource).toContain("grid-template-columns: minmax(0, 1fr);");
expect(periodRightSource).toContain('class="period-selector-row"');
expect(periodRightSource).toContain("grid-template-columns: minmax(0, 1fr) 72px 1rem;");
expect(periodRightSource).toContain("min-height: 4rem;");
expect(periodRightSource).toContain("@media screen and (min-width: 601px) and (max-width: 1023px)"); expect(periodRightSource).toContain("@media screen and (min-width: 601px) and (max-width: 1023px)");
expect(periodRightSource).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));"); expect(periodRightSource).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
expect(periodRightSource).toContain("@media screen and (max-width: 600px)"); expect(periodRightSource).toContain("@media screen and (max-width: 600px)");
@@ -453,6 +460,16 @@ describe("Periode tab contract", () => {
expect(periodViewAllSource).toContain("@container (max-width: 1024px)"); expect(periodViewAllSource).toContain("@container (max-width: 1024px)");
}); });
it("keeps customer card content and actions on stable grid rows", () => {
expect(periodViewAllSource).toContain('"count amount amount"');
expect(periodViewAllSource).toContain('"attributes attributes attributes"');
expect(periodViewAllSource).toContain("grid-template-columns: 1.25rem auto minmax(0, 1fr);");
expect(periodFlagListSource).toContain("grid-template-columns: 1.2rem minmax(0, 1fr);");
expect(periodFlagListSource).toContain("grid-column: 2;");
expect(periodFlagListSource).toContain("max-height: 2.5rem;");
expect(periodFlagListSource).toContain(".invoice-period-flag-row:focus-within");
});
it("sizes the inline month selector to the available content width with a readable cap", () => { it("sizes the inline month selector to the available content width with a readable cap", () => {
expect(periodViewHomeSource).toContain('class="invoicing-period-month-entry__selector"'); expect(periodViewHomeSource).toContain('class="invoicing-period-month-entry__selector"');
expect(periodViewHomeSource).toContain("max-width: 32rem;"); expect(periodViewHomeSource).toContain("max-width: 32rem;");
@@ -738,13 +755,29 @@ describe("Periode tab contract", () => {
expect(periodObjectTreeSource).toContain("overflow-wrap: anywhere;"); expect(periodObjectTreeSource).toContain("overflow-wrap: anywhere;");
}); });
it("switches narrow object-tree metadata to a consistent four-column grid", () => {
expect(periodObjectTreeSource).toContain("@container (max-width: 56rem)");
expect(periodObjectTreeSource).toContain("grid-template-columns: repeat(4, minmax(0, 1fr));");
expect(periodObjectTreeSource).toContain("`invoice-period-tree-node-field--${field.key}`");
expect(periodObjectTreeSource).toContain(".invoice-period-tree-node-field--total_net_amount");
expect(periodObjectTreeSource).toContain("grid-column: 4 !important;");
});
it("shows the order department in the object-tree metadata", () => {
expect(periodObjectTreeSource).toContain('key: "department"');
expect(periodObjectTreeSource).toContain('label: commonText("department", "Afdeling")');
expect(periodObjectTreeSource).toContain("const orderDepartmentLabel = (order: any) =>");
expect(periodObjectTreeSource).toContain("order?.department_name || order?.department?.name");
expect(periodObjectTreeSource).toContain("getDepartmentName(departmentId)");
expect(periodObjectTreeSource).not.toContain("object.cashier_name ||");
});
it("exposes object-specific lazy child categories and multi-select operations", () => { it("exposes object-specific lazy child categories and multi-select operations", () => {
[ [
"COLLECTION_ORDERS", "COLLECTION_ORDERS",
"COLLECTION_AGREEMENTS", "COLLECTION_AGREEMENTS",
"COLLECTION_PAYMENTS", "COLLECTION_PAYMENTS",
"COLLECTION_ECONOMIC", "COLLECTION_ECONOMIC",
"ORDER_ITEMS",
"ORDER_ATTACHMENTS_CERTIFICATES", "ORDER_ATTACHMENTS_CERTIFICATES",
"ORDER_ATTACHMENTS_IMAGES", "ORDER_ATTACHMENTS_IMAGES",
"ORDER_ATTACHMENTS_OTHER", "ORDER_ATTACHMENTS_OTHER",
@@ -755,6 +788,8 @@ describe("Periode tab contract", () => {
].forEach((category) => { ].forEach((category) => {
expect(periodTreeNodeServiceSource).toContain(category); expect(periodTreeNodeServiceSource).toContain(category);
}); });
expect(periodObjectTreeSource).toContain("buildOrderItemTableRows");
expect(periodObjectTreeSource).not.toContain("TREE_CATEGORY_TYPES.ORDER_ITEMS");
[ [
"runCollectionBulkAction", "runCollectionBulkAction",