1254 lines
39 KiB
Vue
1254 lines
39 KiB
Vue
<template>
|
|
<div class="reports-page">
|
|
<h1 class="page-title-sr-only">查询与报表</h1>
|
|
<header class="page-header page-header--actions-only" aria-label="页面操作">
|
|
<t-space break-line>
|
|
<t-button variant="outline" :loading="loading" @click="refreshReport">
|
|
<template #icon><t-icon name="refresh" /></template>刷新
|
|
</t-button>
|
|
<t-button v-if="canExport" theme="primary" :loading="exporting" @click="prepareExportConfirm">
|
|
<template #icon><t-icon name="download" /></template>导出当前报表
|
|
</t-button>
|
|
</t-space>
|
|
</header>
|
|
|
|
<t-alert v-if="errorMessage" theme="error" :close-btn="false">
|
|
<div class="alert-content">
|
|
<span>{{ errorMessage }}</span>
|
|
<t-button variant="text" theme="primary" size="small" @click="refreshReport">重新加载</t-button>
|
|
</div>
|
|
</t-alert>
|
|
|
|
<t-tabs v-model="reportCode" @change="reportChanged">
|
|
<t-tab-panel v-for="report in reportOptions" :key="report.value" :value="report.value" :label="report.label" />
|
|
</t-tabs>
|
|
|
|
<section class="filter-panel" aria-label="报表查询条件">
|
|
<t-form :data="filters" label-align="top" @submit="search">
|
|
<div class="filter-grid">
|
|
<t-form-item label="关键词">
|
|
<t-input v-model="filters.keyword" clearable placeholder="业务编号、项目或公司" @enter="search" />
|
|
</t-form-item>
|
|
<t-form-item label="公司编号">
|
|
<t-input v-model="filters.companyId" clearable maxlength="26" />
|
|
</t-form-item>
|
|
<t-form-item label="项目编号">
|
|
<t-input v-model="filters.projectId" clearable maxlength="26" />
|
|
</t-form-item>
|
|
<t-form-item label="往来单位编号">
|
|
<t-input v-model="filters.counterpartyId" clearable maxlength="26" />
|
|
</t-form-item>
|
|
<t-form-item label="状态">
|
|
<t-select v-model="filters.status" :options="statusOptions" clearable placeholder="全部状态" />
|
|
</t-form-item>
|
|
<t-form-item :label="dateBasisLabel">
|
|
<t-date-range-picker v-model="dateRange" value-type="YYYY-MM-DD" />
|
|
</t-form-item>
|
|
<div class="filter-actions">
|
|
<t-button theme="primary" type="submit">
|
|
<template #icon><t-icon name="search" /></template>查询
|
|
</t-button>
|
|
<t-button variant="outline" type="button" @click="resetFilters">重置</t-button>
|
|
</div>
|
|
</div>
|
|
</t-form>
|
|
</section>
|
|
|
|
<section class="summary-band" aria-label="报表汇总">
|
|
<div>
|
|
<span>记录数</span><strong>{{ meta.summary.totalElements || meta.totalElements }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>{{ meta.summary.metricLabel || '指标合计' }}</span>
|
|
<strong :class="{ 'summary-blocked': hasUnsafeCurrencyAggregate }">
|
|
{{ summaryMetricDisplay }}
|
|
</strong>
|
|
</div>
|
|
<div>
|
|
<span>口径版本</span><strong>{{ meta.definitionVersion || '-' }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>筛选快照</span>
|
|
<strong class="hash">{{ meta.filterHash ? meta.filterHash.slice(0, 16) : '-' }}</strong>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="table-panel" aria-label="报表数据">
|
|
<div class="table-toolbar">
|
|
<span>共 {{ meta.totalElements }} 条记录</span><span>{{ activeReport.description }}</span>
|
|
</div>
|
|
<t-table
|
|
v-if="loading || rows.length"
|
|
:columns="columns"
|
|
:data="rows"
|
|
row-key="rowId"
|
|
:loading="loading"
|
|
:hover="true"
|
|
table-layout="fixed"
|
|
:horizontal-scroll-affixed-bottom="true"
|
|
>
|
|
<template #businessNo="{ row }">
|
|
<t-button variant="text" theme="primary" size="small" @click="openDrilldown(row)">
|
|
{{ display(row.businessNo, 'businessNo') }}
|
|
</t-button>
|
|
</template>
|
|
<template #status="{ row }">
|
|
<t-tag :theme="statusTheme(String(row.status || ''))" variant="light">
|
|
{{ statusLabel(String(row.status || '')) }}
|
|
</t-tag>
|
|
</template>
|
|
<template #operations="{ row }">
|
|
<t-button size="small" variant="text" theme="primary" @click="openDrilldown(row)">
|
|
<template #icon><t-icon name="browse" /></template>查看明细
|
|
</t-button>
|
|
</template>
|
|
</t-table>
|
|
<t-empty v-else description="当前筛选条件下暂无报表数据" />
|
|
<div v-if="meta.totalElements" class="pagination-row">
|
|
<t-pagination
|
|
v-model="pagination.current"
|
|
v-model:page-size="pagination.pageSize"
|
|
:total="meta.totalElements"
|
|
:page-size-options="isMobile ? [] : [20, 50, 100]"
|
|
:show-jumper="!isMobile"
|
|
:theme="isMobile ? 'simple' : 'default'"
|
|
@current-change="pageChanged"
|
|
@page-size-change="pageSizeChanged"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<t-drawer
|
|
v-model:visible="detailVisible"
|
|
:header="`${activeReport.label}明细`"
|
|
size="min(920px, 96vw)"
|
|
:footer="false"
|
|
destroy-on-close
|
|
>
|
|
<t-loading :loading="detailLoading" show-overlay>
|
|
<t-alert v-if="detailError" theme="error" :close-btn="false">{{ detailError }}</t-alert>
|
|
<template v-if="detail">
|
|
<div class="detail-contract">
|
|
<span>{{ detail.definitionVersion }}</span>
|
|
<span class="hash">{{ detail.filterHash.slice(0, 16) }}</span>
|
|
</div>
|
|
<t-descriptions bordered :column="isMobile ? 1 : 2" item-layout="vertical">
|
|
<t-descriptions-item
|
|
v-for="entry in sourceEntries"
|
|
:key="entry.key"
|
|
:label="columnLabel(entry.key)"
|
|
:span="!isMobile && wideKeys.includes(entry.key) ? 2 : 1"
|
|
>
|
|
{{ display(entry.value, entry.key) }}
|
|
</t-descriptions-item>
|
|
</t-descriptions>
|
|
|
|
<section class="detail-section">
|
|
<div class="section-heading">
|
|
<h2>关联指标</h2>
|
|
<span>{{ detail.groups.length }} 项</span>
|
|
</div>
|
|
<div class="metric-grid">
|
|
<div v-for="group in detail.groups" :key="group.code" class="metric-item">
|
|
<span>{{ group.label }}</span
|
|
><strong>{{ displayMetricGroup(group.value, group.code) }}</strong>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="detail-section">
|
|
<div class="section-heading">
|
|
<h2>业务明细</h2>
|
|
<span>{{ detail.relatedRecords.length }} 条</span>
|
|
</div>
|
|
<t-table
|
|
v-if="detail.relatedRecords.length"
|
|
:columns="relatedColumns"
|
|
:data="detail.relatedRecords"
|
|
row-key="publicId"
|
|
size="small"
|
|
table-layout="fixed"
|
|
:horizontal-scroll-affixed-bottom="true"
|
|
/>
|
|
<t-empty v-else description="暂无可关联的业务明细" />
|
|
</section>
|
|
|
|
<section class="detail-section">
|
|
<div class="section-heading">
|
|
<h2>来源表单</h2>
|
|
<span>{{ detail.sourceForms.length }} 条</span>
|
|
</div>
|
|
<t-table
|
|
v-if="detail.sourceForms.length"
|
|
:columns="sourceFormColumns"
|
|
:data="detail.sourceForms"
|
|
row-key="publicId"
|
|
size="small"
|
|
table-layout="fixed"
|
|
:horizontal-scroll-affixed-bottom="true"
|
|
/>
|
|
<t-empty v-else description="当前数据尚未关联来源表单" />
|
|
</section>
|
|
|
|
<section class="detail-section">
|
|
<div class="section-heading">
|
|
<h2>审批记录</h2>
|
|
<span>{{ detail.approvals.length }} 条</span>
|
|
</div>
|
|
<t-table
|
|
v-if="detail.approvals.length"
|
|
:columns="approvalColumns"
|
|
:data="detail.approvals"
|
|
row-key="publicId"
|
|
size="small"
|
|
table-layout="fixed"
|
|
:horizontal-scroll-affixed-bottom="true"
|
|
/>
|
|
<t-empty v-else description="当前数据尚无审批记录" />
|
|
</section>
|
|
|
|
<section class="detail-section">
|
|
<div class="section-heading">
|
|
<h2>关联附件</h2>
|
|
<span>{{ detail.attachments.length }} 个</span>
|
|
</div>
|
|
<t-table
|
|
v-if="detail.attachments.length"
|
|
:columns="attachmentColumns"
|
|
:data="detail.attachments"
|
|
row-key="fileId"
|
|
size="small"
|
|
table-layout="fixed"
|
|
:horizontal-scroll-affixed-bottom="true"
|
|
/>
|
|
<t-empty v-else description="当前数据尚未关联附件" />
|
|
</section>
|
|
</template>
|
|
</t-loading>
|
|
</t-drawer>
|
|
|
|
<t-dialog
|
|
v-model:visible="exportConfirmVisible"
|
|
header="确认导出当前报表"
|
|
width="min(560px, 94vw)"
|
|
:footer="false"
|
|
:close-on-overlay-click="false"
|
|
>
|
|
<t-alert theme="info" :close-btn="false">{{ exportPreview?.maskingNotice }}</t-alert>
|
|
<t-descriptions bordered :column="1" class="export-summary">
|
|
<t-descriptions-item label="报表">{{ exportReportLabel }}</t-descriptions-item>
|
|
<t-descriptions-item label="预计行数">{{ exportPreview?.estimatedRows }} 条</t-descriptions-item>
|
|
<t-descriptions-item :label="exportDateBasisLabel">{{ exportDateRange }}</t-descriptions-item>
|
|
<t-descriptions-item label="冻结排序">{{ exportPreview?.sort }}</t-descriptions-item>
|
|
<t-descriptions-item label="筛选快照"
|
|
><span class="hash">{{ exportPreview?.filterHash }}</span></t-descriptions-item
|
|
>
|
|
</t-descriptions>
|
|
<div class="dialog-footer">
|
|
<t-button variant="outline" :disabled="exporting" @click="invalidateExport">取消</t-button>
|
|
<t-button theme="primary" :loading="exporting" @click="downloadReport">确认并导出</t-button>
|
|
</div>
|
|
</t-dialog>
|
|
</div>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import dayjs from 'dayjs';
|
|
import type { PrimaryTableCol } from 'tdesign-vue-next';
|
|
import { Button as TButton, MessagePlugin } from 'tdesign-vue-next';
|
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
|
|
import type {
|
|
ReportCode,
|
|
ReportDrilldown,
|
|
ReportExportPreview,
|
|
ReportExportSnapshot,
|
|
ReportPageMeta,
|
|
ReportQuery,
|
|
ReportRow,
|
|
} from '@/api/reports';
|
|
import { confirmReportExport, getReport, getReportDrilldown, prepareReportExport } from '@/api/reports';
|
|
import { useUserStore } from '@/store';
|
|
|
|
defineOptions({ name: 'ReportsPage' });
|
|
|
|
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
|
|
type ReportFilters = Omit<ReportQuery, 'page' | 'size'>;
|
|
|
|
interface ReportOption {
|
|
label: string;
|
|
value: ReportCode;
|
|
description: string;
|
|
}
|
|
|
|
interface AppliedSnapshot {
|
|
reportCode: ReportCode;
|
|
filters: ReportFilters;
|
|
definitionVersion: string;
|
|
filterHash: string;
|
|
}
|
|
|
|
const reportOptions: ReportOption[] = [
|
|
{ label: '项目台账', value: 'project-ledger', description: '项目合同、收款、成本、实付、记账和归档状态总览' },
|
|
{ label: '合同执行', value: 'contract-execution', description: '合同、变更、结算、净开票、应付和已付口径' },
|
|
{ label: '收款与开票', value: 'receipt-invoice', description: '本次及累计收款、净开票和未收金额' },
|
|
{ label: '付款进度', value: 'payment-progress', description: '付款申请、审核、线下结果、回单和记账状态' },
|
|
{ label: '流程时长', value: 'workflow-duration', description: '审批节点实时耗时、退回次数和未处理任务' },
|
|
{ label: '资料完整性', value: 'archive-completeness', description: '项目最新档案包的缺失、待补、不涉及和已齐全数据' },
|
|
];
|
|
const reportStatusValues: Record<ReportCode, string[]> = {
|
|
'project-ledger': ['DRAFT', 'ACTIVE', 'SUSPENDED', 'CLOSED', 'ARCHIVED'],
|
|
'contract-execution': ['DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'SETTLED', 'DISABLED', 'VOID'],
|
|
'receipt-invoice': ['CONFIRMED', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'ISSUED', 'RED_LETTERED'],
|
|
'payment-progress': [
|
|
'DRAFT',
|
|
'REVIEWING',
|
|
'RETURNED',
|
|
'REJECTED',
|
|
'APPROVED',
|
|
'OFFLINE_PENDING',
|
|
'RESULT_RECORDED',
|
|
'RESULT_VERIFIED',
|
|
'PART_PAID',
|
|
'PAID',
|
|
'FAILED',
|
|
'REFUNDED',
|
|
'VOID',
|
|
],
|
|
'workflow-duration': ['PENDING', 'APPROVED', 'RETURNED', 'REJECTED', 'CANCELLED'],
|
|
'archive-completeness': ['NOT_CREATED', 'PREPARING', 'CHECKED', 'REVIEWING', 'RETURNED', 'ARCHIVED', 'FROZEN'],
|
|
};
|
|
const dateBasisLabels: Record<ReportCode, string> = {
|
|
'project-ledger': '项目数据变更日期',
|
|
'contract-execution': '合同数据变更日期',
|
|
'receipt-invoice': '收款/开票业务日期',
|
|
'payment-progress': '付款申请日期',
|
|
'workflow-duration': '任务到达日期',
|
|
'archive-completeness': '档案包更新日期',
|
|
};
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const userStore = useUserStore();
|
|
const queryString = (key: string) => (typeof route.query[key] === 'string' ? String(route.query[key]) : '');
|
|
const isReportCode = (value: string): value is ReportCode => reportOptions.some((item) => item.value === value);
|
|
const monthRange = (): string[] => [
|
|
dayjs().startOf('month').format('YYYY-MM-DD'),
|
|
dayjs().endOf('month').format('YYYY-MM-DD'),
|
|
];
|
|
const queryDateRange = () => {
|
|
const from = queryString('dateFrom');
|
|
const to = queryString('dateTo');
|
|
return /^\d{4}-\d{2}-\d{2}$/.test(from) && /^\d{4}-\d{2}-\d{2}$/.test(to) ? [from, to] : monthRange();
|
|
};
|
|
|
|
const requestedReport = queryString('reportCode');
|
|
const reportCode = ref<ReportCode>(isReportCode(requestedReport) ? requestedReport : 'project-ledger');
|
|
const activeReport = computed(() => reportOptions.find((item) => item.value === reportCode.value) || reportOptions[0]);
|
|
const dateBasisLabel = computed(() => dateBasisLabels[reportCode.value]);
|
|
const statusOptions = computed(() =>
|
|
reportStatusValues[reportCode.value].map((value) => ({ value, label: statusLabel(value) })),
|
|
);
|
|
const filters = reactive({
|
|
companyId: queryString('companyId'),
|
|
projectId: queryString('projectId'),
|
|
counterpartyId: queryString('counterpartyId'),
|
|
keyword: queryString('keyword'),
|
|
status: queryString('status'),
|
|
});
|
|
const dateRange = ref<string[]>(queryDateRange());
|
|
const pagination = reactive({
|
|
current: Math.max(1, Number(queryString('page')) || 1),
|
|
pageSize: [20, 50, 100].includes(Number(queryString('size'))) ? Number(queryString('size')) : 20,
|
|
});
|
|
const rows = ref<ReportRow[]>([]);
|
|
const loading = ref(false);
|
|
const exporting = ref(false);
|
|
const exportConfirmVisible = ref(false);
|
|
const exportPreview = ref<ReportExportPreview | null>(null);
|
|
const exportSnapshot = ref<ReportExportSnapshot | null>(null);
|
|
const errorMessage = ref('');
|
|
const appliedSnapshot = ref<AppliedSnapshot | null>(null);
|
|
let loadSequence = 0;
|
|
let syncingUrl = false;
|
|
|
|
const emptyMeta = (): ReportPageMeta => ({
|
|
page: 1,
|
|
size: 20,
|
|
totalElements: 0,
|
|
totalPages: 0,
|
|
reportCode: reportCode.value,
|
|
definitionVersion: '',
|
|
filterHash: '',
|
|
columns: [],
|
|
sort: '',
|
|
summary: {},
|
|
allowedActions: [],
|
|
});
|
|
const meta = reactive<ReportPageMeta>(emptyMeta());
|
|
const canExport = computed(() => meta.allowedActions.includes('EXPORT'));
|
|
const canView = computed(() => userStore.hasPermission('report:finance:view'));
|
|
const moneySummaryReports = new Set<ReportCode>([
|
|
'project-ledger',
|
|
'contract-execution',
|
|
'receipt-invoice',
|
|
'payment-progress',
|
|
]);
|
|
const hasUnsafeCurrencyAggregate = computed(() => moneySummaryReports.has(reportCode.value));
|
|
const summaryMetricDisplay = computed(() =>
|
|
hasUnsafeCurrencyAggregate.value ? '未按币种分组,已阻断合计' : formatMetric(meta.summary.metricTotal),
|
|
);
|
|
const exportDateRange = computed(() => formatDateRange(exportSnapshot.value?.filters));
|
|
const exportReportLabel = computed(() => {
|
|
const code = exportSnapshot.value?.reportCode;
|
|
return reportOptions.find((item) => item.value === code)?.label || '-';
|
|
});
|
|
const exportDateBasisLabel = computed(() => {
|
|
const code = exportSnapshot.value?.reportCode;
|
|
return code ? dateBasisLabels[code] : '日期范围';
|
|
});
|
|
|
|
const labels: Record<string, string> = {
|
|
rowId: '明细编号',
|
|
businessNo: '业务编号',
|
|
projectName: '项目',
|
|
companyName: '公司',
|
|
contractAmount: '合同额',
|
|
receiptAmount: '收款额',
|
|
costAmount: '成本额',
|
|
paymentAmount: '实际付款额',
|
|
completedVoucherCount: '完成凭证数',
|
|
pendingAccountingCount: '待记账数',
|
|
completeness: '完整率',
|
|
latestPackageStatus: '最新档案状态',
|
|
name: '合同名称',
|
|
originalAmount: '合同原额',
|
|
approvedChangeAmount: '已批变更',
|
|
settlementAmount: '结算额',
|
|
invoicedAmount: '净开票额',
|
|
pendingPayableAmount: '待确认应付',
|
|
payableAmount: '应付金额',
|
|
paidAmount: '已付金额',
|
|
availableAmount: '可付余额',
|
|
recordType: '记录类型',
|
|
businessDate: '业务日期',
|
|
amount: '本次金额',
|
|
cumulativeReceiptAmount: '累计收款',
|
|
cumulativeInvoiceAmount: '累计净开票',
|
|
outstandingAmount: '未收金额',
|
|
allocatedAmount: '已分配',
|
|
invoiceNo: '发票号码',
|
|
invoiceType: '发票类型',
|
|
requestedDate: '申请日期',
|
|
requestedAmount: '申请金额',
|
|
approvedAmount: '批准金额',
|
|
submittedAt: '提交时间',
|
|
checkedAt: '财务检查时间',
|
|
approvedAt: '批准时间',
|
|
offlineResult: '线下结果',
|
|
resultAmount: '结果金额',
|
|
paidDate: '支付日期',
|
|
transactionNo: '交易流水号',
|
|
receiptFileCount: '回单数',
|
|
accountingStatus: '记账状态',
|
|
formType: '表单类型',
|
|
nodeCode: '节点编码',
|
|
arrivedAt: '到达时间',
|
|
completedAt: '完成时间',
|
|
durationSeconds: '耗时(秒)',
|
|
returnCount: '退回次数',
|
|
pendingTaskCount: '未处理任务数',
|
|
packageVersion: '档案包版本',
|
|
missingCount: '缺失数',
|
|
pendingCount: '待补数',
|
|
notApplicableCount: '不涉及数',
|
|
presentCount: '已齐全数',
|
|
totalItemCount: '清单项总数',
|
|
frozen: '冻结',
|
|
status: '状态',
|
|
companyPublicId: '公司编号',
|
|
projectPublicId: '项目编号',
|
|
counterpartyPublicId: '往来单位编号',
|
|
sourceDocumentId: '来源表单编号',
|
|
sourceVersionId: '来源版本编号',
|
|
workflowInstanceId: '流程实例编号',
|
|
updatedAt: '更新时间',
|
|
metricAmount: '指标值',
|
|
fileCount: '文件数',
|
|
currency: '币种',
|
|
stage: '项目阶段',
|
|
financeStatus: '财务状态',
|
|
archiveStatus: '归档状态',
|
|
};
|
|
const wideKeys = ['rowId', 'companyPublicId', 'projectPublicId', 'counterpartyPublicId', 'sourceDocumentId'];
|
|
|
|
function isMetricColumn(key: string) {
|
|
return /amount/i.test(key) || ['completeness', 'durationSeconds'].includes(key);
|
|
}
|
|
|
|
const columns = computed<PrimaryTableCol[]>(() => {
|
|
const source = meta.columns.length ? meta.columns : ['businessNo', 'projectName', 'status'];
|
|
const result = source.map((key): PrimaryTableCol => ({
|
|
colKey: key,
|
|
title: columnLabel(key),
|
|
width: columnWidth(key),
|
|
ellipsis: true,
|
|
align: isMetricColumn(key) ? 'right' : undefined,
|
|
fixed: ['businessNo', 'projectName', 'name'].includes(key) ? 'left' : undefined,
|
|
cell:
|
|
key === 'businessNo' || key === 'status'
|
|
? undefined
|
|
: isMetricColumn(key)
|
|
? (h, { row }) => {
|
|
const props = { variant: 'text' as const, theme: 'primary' as const, size: 'small' as const };
|
|
return h(TButton, { ...props, onClick: () => openDrilldown(row) }, () => display(row[key], key));
|
|
}
|
|
: (_h, { row }) => display(row[key], key),
|
|
}));
|
|
result.push({ colKey: 'operations', title: '操作', width: 120, fixed: 'right' });
|
|
return result;
|
|
});
|
|
|
|
const relatedColumns: PrimaryTableCol[] = [
|
|
{ colKey: 'relationType', title: '关联类型', width: 130 },
|
|
{ colKey: 'businessNo', title: '业务编号', width: 190, ellipsis: true },
|
|
{ colKey: 'status', title: '状态', width: 120, cell: (_h, { row }) => statusLabel(String(row.status || '')) },
|
|
{
|
|
colKey: 'amount',
|
|
title: '金额/指标',
|
|
width: 140,
|
|
align: 'right',
|
|
cell: (_h, { row }) => display(row.amount, 'amount'),
|
|
},
|
|
{ colKey: 'businessDate', title: '业务日期', width: 130 },
|
|
{ colKey: 'sourceDocumentId', title: '来源表单编号', width: 220, ellipsis: true },
|
|
];
|
|
const sourceFormColumns: PrimaryTableCol[] = [
|
|
{ colKey: 'businessNo', title: '表单业务编号', width: 190, fixed: 'left', ellipsis: true },
|
|
{ colKey: 'formType', title: '表单类型', width: 110 },
|
|
{ colKey: 'sourceNo', title: '来源编号', width: 170, ellipsis: true },
|
|
{ colKey: 'versionNo', title: '版本', width: 80 },
|
|
{ colKey: 'relationType', title: '关联关系', width: 130 },
|
|
{ colKey: 'status', title: '状态', width: 120, cell: (_h, { row }) => statusLabel(String(row.status || '')) },
|
|
];
|
|
const approvalColumns: PrimaryTableCol[] = [
|
|
{ colKey: 'nodeCode', title: '节点', width: 130 },
|
|
{ colKey: 'action', title: '动作', width: 110 },
|
|
{ colKey: 'actorName', title: '处理人', width: 120 },
|
|
{ colKey: 'opinion', title: '意见', width: 220, ellipsis: true },
|
|
{ colKey: 'actedAt', title: '处理时间', width: 180, cell: (_h, { row }) => display(row.actedAt, 'actedAt') },
|
|
];
|
|
const attachmentColumns: PrimaryTableCol[] = [
|
|
{ colKey: 'displayName', title: '文件名', width: 220, ellipsis: true },
|
|
{ colKey: 'documentType', title: '资料类型', width: 150 },
|
|
{ colKey: 'ownerType', title: '来源对象', width: 140 },
|
|
{
|
|
colKey: 'scanStatus',
|
|
title: '文件检查',
|
|
width: 120,
|
|
cell: (_h, { row }) => scanStatusLabel(String(row.scanStatus || '')),
|
|
},
|
|
{ colKey: 'versionNo', title: '版本', width: 80 },
|
|
{ colKey: 'fileId', title: '文件编号', width: 220, ellipsis: true },
|
|
];
|
|
|
|
function columnLabel(key: string) {
|
|
return labels[key] || key;
|
|
}
|
|
|
|
function columnWidth(key: string) {
|
|
if (['projectName', 'name'].includes(key)) return 200;
|
|
if (['businessNo', 'arrivedAt', 'completedAt', 'submittedAt', 'checkedAt', 'approvedAt'].includes(key)) return 180;
|
|
if (/amount|total/i.test(key)) return 140;
|
|
return 120;
|
|
}
|
|
|
|
function formatMetric(value?: string) {
|
|
const number = Number(value || 0);
|
|
return Number.isFinite(number)
|
|
? new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
|
|
: value || '-';
|
|
}
|
|
|
|
function display(value: unknown, key: string) {
|
|
if (value === null || value === undefined || value === '') return '-';
|
|
if (key === 'status') return statusLabel(String(value));
|
|
if (key === 'scanStatus') return scanStatusLabel(String(value));
|
|
if (['financeStatus', 'archiveStatus', 'latestPackageStatus', 'offlineResult', 'accountingStatus'].includes(key)) {
|
|
return statusLabel(String(value));
|
|
}
|
|
if (key === 'frozen') return value ? '是' : '否';
|
|
if (key === 'completeness') return `${Number(value).toFixed(2).replace(/\.00$/, '')}%`;
|
|
if (/amount|total/i.test(key)) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number)
|
|
? new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
|
|
: String(value);
|
|
}
|
|
if (/At$/.test(key)) {
|
|
const date = new Date(String(value));
|
|
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false });
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function displayMetricGroup(value: string, code: string) {
|
|
if (
|
|
[
|
|
'CONTRACT',
|
|
'RECEIPT',
|
|
'COST',
|
|
'PAYMENT',
|
|
'INVOICE',
|
|
'PAYABLE',
|
|
'PAID',
|
|
'AVAILABLE',
|
|
'CURRENT',
|
|
'CUMULATIVE_RECEIPT',
|
|
'CUMULATIVE_INVOICE',
|
|
'OUTSTANDING',
|
|
'REQUESTED',
|
|
'APPROVED',
|
|
'PAYMENT_RESULT',
|
|
].includes(code)
|
|
) {
|
|
return formatMetric(value);
|
|
}
|
|
if (code === 'ARCHIVE') {
|
|
return `${Number(value || 0)
|
|
.toFixed(2)
|
|
.replace(/\.00$/, '')}%`;
|
|
}
|
|
return value || '-';
|
|
}
|
|
|
|
function statusLabel(value: string) {
|
|
return (
|
|
(
|
|
{
|
|
ACTIVE: '进行中',
|
|
DRAFT: '草稿',
|
|
REVIEWING: '审核中',
|
|
SETTLED: '已结算',
|
|
DISABLED: '已停用',
|
|
VOID: '已作废',
|
|
SUSPENDED: '已暂停',
|
|
CLOSED: '已关闭',
|
|
IN_PROGRESS: '进行中',
|
|
NOT_READY: '未就绪',
|
|
CHECKING: '检查中',
|
|
COMPLETED: '已完成',
|
|
APPROVED: '已批准',
|
|
ARCHIVED: '已归档',
|
|
FROZEN: '已冻结',
|
|
RETURNED: '已退回',
|
|
REJECTED: '已拒绝',
|
|
PAID: '已付款',
|
|
ISSUED: '已开票',
|
|
RED_LETTERED: '已红冲',
|
|
NOT_CREATED: '未生成',
|
|
PENDING: '待处理',
|
|
PREPARING: '准备中',
|
|
CHECKED: '已检查',
|
|
PART_PAID: '部分已付',
|
|
REFUNDED: '已退款',
|
|
FAILED: '失败',
|
|
CANCELLED: '已取消',
|
|
CONFIRMED: '已确认',
|
|
PARTIALLY_ALLOCATED: '部分核销',
|
|
ALLOCATED: '已核销',
|
|
RESULT_VERIFIED: '结果已复核',
|
|
RESULT_RECORDED: '结果待复核',
|
|
OFFLINE_PENDING: '待线下处理',
|
|
SUCCESS: '成功',
|
|
FAILURE: '失败',
|
|
REFUND: '退款',
|
|
} as Record<string, string>
|
|
)[value] ||
|
|
value ||
|
|
'-'
|
|
);
|
|
}
|
|
|
|
function statusTheme(value: string): TagTheme {
|
|
if (['ACTIVE', 'COMPLETED', 'APPROVED', 'ARCHIVED', 'PAID', 'ISSUED', 'RESULT_VERIFIED'].includes(value)) {
|
|
return 'success';
|
|
}
|
|
if (['RETURNED', 'REJECTED', 'FROZEN', 'FAILED'].includes(value)) return 'danger';
|
|
if (value === 'NOT_CREATED') return 'default';
|
|
return 'warning';
|
|
}
|
|
|
|
function scanStatusLabel(value: string) {
|
|
return (
|
|
(
|
|
{
|
|
SCANNING: '扫描中',
|
|
AVAILABLE: '可用',
|
|
REJECTED: '已拒绝',
|
|
QUARANTINED: '隔离',
|
|
SUPERSEDED: '已替代',
|
|
} as Record<string, string>
|
|
)[value] ||
|
|
value ||
|
|
'-'
|
|
);
|
|
}
|
|
|
|
function draftFilters(): ReportFilters {
|
|
return {
|
|
companyId: filters.companyId.trim() || undefined,
|
|
projectId: filters.projectId.trim() || undefined,
|
|
counterpartyId: filters.counterpartyId.trim() || undefined,
|
|
keyword: filters.keyword.trim() || undefined,
|
|
status: filters.status.trim() || undefined,
|
|
dateFrom: dateRange.value[0] || undefined,
|
|
dateTo: dateRange.value[1] || undefined,
|
|
};
|
|
}
|
|
|
|
function appliedReportSnapshot(): AppliedSnapshot | null {
|
|
const snapshot = appliedSnapshot.value;
|
|
if (!snapshot || snapshot.reportCode !== reportCode.value || !snapshot.definitionVersion || !snapshot.filterHash) {
|
|
MessagePlugin.warning('报表尚未完成加载,请刷新后重试');
|
|
return null;
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function formatDateRange(snapshot?: ReportFilters) {
|
|
if (!snapshot?.dateFrom && !snapshot?.dateTo) return '全部日期';
|
|
return `${snapshot.dateFrom || '最早'} 至 ${snapshot.dateTo || '最新'}`;
|
|
}
|
|
|
|
async function syncUrl(snapshot: AppliedSnapshot) {
|
|
const params = {
|
|
reportCode: snapshot.reportCode,
|
|
...snapshot.filters,
|
|
page: pagination.current,
|
|
size: pagination.pageSize,
|
|
};
|
|
const query = Object.fromEntries(
|
|
Object.entries(params)
|
|
.filter(([, value]) => value !== undefined && value !== '')
|
|
.map(([key, value]) => [key, String(value)]),
|
|
);
|
|
syncingUrl = true;
|
|
try {
|
|
await router.replace({ name: 'ReportsIndex', query });
|
|
} finally {
|
|
syncingUrl = false;
|
|
}
|
|
}
|
|
|
|
async function loadReport(applyDraft = false) {
|
|
if (!canView.value) {
|
|
rows.value = [];
|
|
Object.assign(meta, emptyMeta());
|
|
errorMessage.value = '当前身份没有查看财务报表的权限';
|
|
return false;
|
|
}
|
|
const sequence = ++loadSequence;
|
|
const code = reportCode.value;
|
|
if (applyDraft || !appliedSnapshot.value || appliedSnapshot.value.reportCode !== code) {
|
|
appliedSnapshot.value = { reportCode: code, filters: draftFilters(), definitionVersion: '', filterHash: '' };
|
|
}
|
|
const requestSnapshot = appliedSnapshot.value;
|
|
let loadedSnapshot: AppliedSnapshot | null = null;
|
|
loading.value = true;
|
|
errorMessage.value = '';
|
|
try {
|
|
const result = await getReport(requestSnapshot.reportCode, {
|
|
...requestSnapshot.filters,
|
|
page: pagination.current,
|
|
size: pagination.pageSize,
|
|
});
|
|
if (sequence !== loadSequence) return false;
|
|
rows.value = result.items;
|
|
Object.assign(meta, result.meta);
|
|
loadedSnapshot = {
|
|
reportCode: requestSnapshot.reportCode,
|
|
filters: { ...requestSnapshot.filters },
|
|
definitionVersion: result.meta.definitionVersion,
|
|
filterHash: result.meta.filterHash,
|
|
};
|
|
appliedSnapshot.value = loadedSnapshot;
|
|
} catch (error) {
|
|
if (sequence !== loadSequence) return false;
|
|
errorMessage.value = (error as Error).message || '报表加载失败';
|
|
MessagePlugin.error(errorMessage.value);
|
|
rows.value = [];
|
|
Object.assign(meta, emptyMeta());
|
|
return false;
|
|
} finally {
|
|
if (sequence === loadSequence) loading.value = false;
|
|
}
|
|
if (sequence !== loadSequence || !loadedSnapshot) return false;
|
|
try {
|
|
await syncUrl(loadedSnapshot);
|
|
} catch {
|
|
MessagePlugin.warning('报表已加载,但网址状态同步失败');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function refreshReport() {
|
|
void loadReport(false);
|
|
}
|
|
|
|
function search() {
|
|
invalidateDrilldown();
|
|
invalidateExport();
|
|
pagination.current = 1;
|
|
void loadReport(true);
|
|
}
|
|
|
|
function reportChanged() {
|
|
invalidateDrilldown();
|
|
invalidateExport();
|
|
filters.status = '';
|
|
pagination.current = 1;
|
|
rows.value = [];
|
|
Object.assign(meta, emptyMeta());
|
|
void loadReport(true);
|
|
}
|
|
|
|
function pageChanged() {
|
|
void loadReport(false);
|
|
}
|
|
|
|
function pageSizeChanged() {
|
|
pagination.current = 1;
|
|
void loadReport(false);
|
|
}
|
|
|
|
function resetFilters() {
|
|
filters.companyId = '';
|
|
filters.projectId = '';
|
|
filters.counterpartyId = '';
|
|
filters.keyword = '';
|
|
filters.status = '';
|
|
dateRange.value = monthRange();
|
|
search();
|
|
}
|
|
|
|
function restoreFromRoute() {
|
|
const requested = queryString('reportCode');
|
|
reportCode.value = isReportCode(requested) ? requested : 'project-ledger';
|
|
filters.companyId = queryString('companyId');
|
|
filters.projectId = queryString('projectId');
|
|
filters.counterpartyId = queryString('counterpartyId');
|
|
filters.keyword = queryString('keyword');
|
|
filters.status = queryString('status');
|
|
dateRange.value = queryDateRange();
|
|
pagination.current = Math.max(1, Number(queryString('page')) || 1);
|
|
pagination.pageSize = [20, 50, 100].includes(Number(queryString('size'))) ? Number(queryString('size')) : 20;
|
|
appliedSnapshot.value = null;
|
|
rows.value = [];
|
|
Object.assign(meta, emptyMeta());
|
|
invalidateDrilldown();
|
|
invalidateExport();
|
|
}
|
|
|
|
const detailVisible = ref(false);
|
|
const detailLoading = ref(false);
|
|
const detailError = ref('');
|
|
const detail = ref<ReportDrilldown | null>(null);
|
|
let detailSequence = 0;
|
|
const sourceEntries = computed(() =>
|
|
Object.entries(detail.value?.source || {}).map(([key, value]) => ({ key, value })),
|
|
);
|
|
|
|
function assertSameSnapshot(definitionVersion: string, filterHash: string) {
|
|
if (definitionVersion !== meta.definitionVersion || filterHash !== meta.filterHash) {
|
|
throw new Error('报表口径或筛选快照已变化,请刷新后重试');
|
|
}
|
|
}
|
|
|
|
async function openDrilldown(row: ReportRow) {
|
|
const rowId = String(row.rowId || row.publicId || '');
|
|
if (!rowId) {
|
|
MessagePlugin.warning('当前报表行缺少明细编号');
|
|
return;
|
|
}
|
|
const snapshot = appliedReportSnapshot();
|
|
if (!snapshot) return;
|
|
const sequence = ++detailSequence;
|
|
detailVisible.value = true;
|
|
detailLoading.value = true;
|
|
detailError.value = '';
|
|
detail.value = null;
|
|
try {
|
|
const result = await getReportDrilldown(snapshot.reportCode, rowId, snapshot.filters);
|
|
if (sequence !== detailSequence) return;
|
|
assertSameSnapshot(result.definitionVersion, result.filterHash);
|
|
detail.value = result;
|
|
} catch (error) {
|
|
if (sequence !== detailSequence) return;
|
|
detailError.value = (error as Error).message || '明细加载失败';
|
|
} finally {
|
|
if (sequence === detailSequence) detailLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function invalidateDrilldown() {
|
|
detailSequence += 1;
|
|
detailVisible.value = false;
|
|
detailLoading.value = false;
|
|
detailError.value = '';
|
|
detail.value = null;
|
|
}
|
|
|
|
function invalidateExport() {
|
|
exportConfirmVisible.value = false;
|
|
exportPreview.value = null;
|
|
exportSnapshot.value = null;
|
|
}
|
|
|
|
async function sha256Hex(value: string) {
|
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
async function downloadReport() {
|
|
const prepared = exportPreview.value;
|
|
const snapshot = exportSnapshot.value;
|
|
if (!prepared || !snapshot || exporting.value) return;
|
|
exporting.value = true;
|
|
try {
|
|
const result = await confirmReportExport(snapshot.reportCode, prepared.exportId);
|
|
if (result.definitionVersion !== snapshot.definitionVersion || result.filterHash !== snapshot.filterHash) {
|
|
throw new Error('导出筛选快照已变化,请重新预检');
|
|
}
|
|
const contentSha256 = await sha256Hex(result.content);
|
|
if (!/^[a-f\d]{64}$/i.test(result.sha256) || contentSha256 !== result.sha256.toLowerCase()) {
|
|
throw new Error('导出文件完整性校验失败,请重新导出');
|
|
}
|
|
const blob = new Blob([result.content], { type: 'text/csv;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = result.fileName;
|
|
document.body.append(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
invalidateExport();
|
|
const refreshed = await loadReport(false);
|
|
if (refreshed) MessagePlugin.success(`已导出 ${result.rowCount} 行,SHA-256 ${result.sha256.slice(0, 16)}...`);
|
|
else MessagePlugin.warning('导出文件已生成,但报表列表重新加载失败,请手动刷新核对最新数据');
|
|
} catch (error) {
|
|
MessagePlugin.error((error as Error).message || '报表导出失败');
|
|
} finally {
|
|
exporting.value = false;
|
|
}
|
|
}
|
|
|
|
async function prepareExportConfirm() {
|
|
const snapshot = appliedReportSnapshot();
|
|
if (!snapshot) return;
|
|
exporting.value = true;
|
|
try {
|
|
const prepared = await prepareReportExport(snapshot.reportCode, {
|
|
...snapshot.filters,
|
|
columns: meta.columns,
|
|
sort: meta.sort,
|
|
expectedFilterHash: snapshot.filterHash,
|
|
expectedDefinitionVersion: snapshot.definitionVersion,
|
|
});
|
|
if (prepared.definitionVersion !== snapshot.definitionVersion || prepared.filterHash !== snapshot.filterHash) {
|
|
throw new Error('导出预检筛选快照已变化,请刷新后重试');
|
|
}
|
|
exportPreview.value = prepared;
|
|
exportSnapshot.value = {
|
|
reportCode: snapshot.reportCode,
|
|
filters: { ...snapshot.filters },
|
|
definitionVersion: snapshot.definitionVersion,
|
|
filterHash: snapshot.filterHash,
|
|
};
|
|
exportConfirmVisible.value = true;
|
|
} catch (error) {
|
|
MessagePlugin.error((error as Error).message || '导出预检失败');
|
|
} finally {
|
|
exporting.value = false;
|
|
}
|
|
}
|
|
|
|
const isMobile = ref(false);
|
|
function updateViewport() {
|
|
isMobile.value = window.innerWidth <= 600;
|
|
}
|
|
|
|
onMounted(() => {
|
|
updateViewport();
|
|
window.addEventListener('resize', updateViewport);
|
|
void loadReport(true);
|
|
});
|
|
|
|
watch(
|
|
() => route.fullPath,
|
|
() => {
|
|
if (syncingUrl) return;
|
|
restoreFromRoute();
|
|
void loadReport(true);
|
|
},
|
|
{ flush: 'sync' },
|
|
);
|
|
|
|
watch(detailVisible, (visible) => {
|
|
if (!visible && detailLoading.value) {
|
|
detailSequence += 1;
|
|
detailLoading.value = false;
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
loadSequence += 1;
|
|
detailSequence += 1;
|
|
window.removeEventListener('resize', updateViewport);
|
|
});
|
|
</script>
|
|
<style scoped>
|
|
.reports-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.page-header,
|
|
.table-toolbar,
|
|
.alert-content,
|
|
.section-heading,
|
|
.detail-contract {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.export-summary {
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.dialog-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.page-header {
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.page-header h1 {
|
|
margin: 0 0 8px;
|
|
font-size: 24px;
|
|
line-height: 32px;
|
|
}
|
|
|
|
.page-header p {
|
|
margin: 0;
|
|
color: var(--td-text-color-secondary);
|
|
}
|
|
|
|
.alert-content {
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
width: 100%;
|
|
}
|
|
|
|
.filter-panel,
|
|
.table-panel {
|
|
min-width: 0;
|
|
padding: 16px;
|
|
background: var(--td-bg-color-container);
|
|
border: 1px solid var(--td-component-border);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.filter-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(170px, 1fr));
|
|
gap: 0 16px;
|
|
align-items: end;
|
|
}
|
|
|
|
.filter-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding-bottom: 24px;
|
|
}
|
|
|
|
.summary-band {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
|
border: 1px solid var(--td-component-border);
|
|
background: var(--td-bg-color-container);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.summary-band > div {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
padding: 16px;
|
|
border-right: 1px solid var(--td-component-border);
|
|
}
|
|
|
|
.summary-band > div:last-child {
|
|
border-right: 0;
|
|
}
|
|
|
|
.summary-band span,
|
|
.metric-item span {
|
|
color: var(--td-text-color-secondary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.summary-band strong {
|
|
overflow: hidden;
|
|
font-size: 20px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.summary-band strong.summary-blocked {
|
|
overflow: visible;
|
|
color: #704000;
|
|
font-size: 14px;
|
|
line-height: 20px;
|
|
text-overflow: clip;
|
|
white-space: normal;
|
|
}
|
|
|
|
.table-toolbar {
|
|
justify-content: space-between;
|
|
margin-bottom: 12px;
|
|
color: var(--td-text-color-secondary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.pagination-row {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
min-height: 32px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.detail-contract {
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
color: var(--td-text-color-secondary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.detail-section {
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.section-heading {
|
|
justify-content: space-between;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.section-heading h2 {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.section-heading span {
|
|
color: var(--td-text-color-secondary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.metric-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(140px, 1fr));
|
|
border: 1px solid var(--td-component-border);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.metric-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
padding: 16px;
|
|
border-right: 1px solid var(--td-component-border);
|
|
}
|
|
|
|
.metric-item:last-child {
|
|
border-right: 0;
|
|
}
|
|
|
|
.metric-item strong {
|
|
overflow-wrap: anywhere;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.hash {
|
|
font-family: var(--td-font-family-mono);
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
@media (width <= 900px) {
|
|
.filter-grid {
|
|
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
|
}
|
|
|
|
.summary-band {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.summary-band > div:nth-child(2) {
|
|
border-right: 0;
|
|
}
|
|
|
|
.summary-band > div:nth-child(-n + 2) {
|
|
border-bottom: 1px solid var(--td-component-border);
|
|
}
|
|
|
|
.table-toolbar {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
}
|
|
|
|
@media (width <= 600px) {
|
|
.page-header {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.filter-grid,
|
|
.summary-band,
|
|
.metric-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.summary-band > div,
|
|
.metric-item {
|
|
border-right: 0;
|
|
border-bottom: 1px solid var(--td-component-border);
|
|
}
|
|
|
|
.summary-band > div:last-child,
|
|
.metric-item:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.pagination-row {
|
|
justify-content: center;
|
|
}
|
|
|
|
.pagination-row :deep(.t-pagination) {
|
|
justify-content: center;
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
</style>
|